86e3aef6f7
원인: wace JSP의 var columns 끝에 있는 /* 주석처리된 컬럼 */ 블록은 비활성 보존 영역(메모리 feedback_wace_jsp_columns)인데, V1 보강 커밋에서 추측으로 활성화함. 운영 화면에 노출되지 않는 컬럼이므로 원본대로 주석으로 되돌림. 비활성으로 복귀한 컬럼: - 견적관리(estimateList_new.jsp 494~502): 제품구분 / 국내해외 / 반납사유 - 주문관리(orderMgmtList.jsp 429~434): 제품구분 / 국내해외 / 접수일 - 판매관리(salesMgmtList.jsp 503~519): 제품구분 / 국내해외 / 접수일 / 고객사요청사항 / 주문서첨부 / 출하방법 / 담당자 / 인도조건 - 매출관리(revenueMgmtList.jsp 615~632): 접수일 / 유무상 / 요청납기 / 고객사요청사항 / 수주상태 / 주문서첨부 / 출하방법 / 담당자 / 인도조건 코드는 주석 블록으로 남겨두어 필요시 활성화 가능. 백엔드 SQL은 그대로 두어 다른 메뉴에서 재사용 가능. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
18 KiB
TypeScript
327 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Save, Loader2, Search, Truck } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
|
import { PartSelect } from "@/components/common/PartSelect";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale";
|
|
|
|
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
|
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
|
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
|
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
|
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
|
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
|
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
|
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
|
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
|
{ key: "sales_quantity", label: "판매수량", width: "w-[90px]", align: "right", formatNumber: true },
|
|
{ key: "remaining_quantity", label: "잔량", width: "w-[80px]", align: "right", formatNumber: true },
|
|
{ key: "sales_unit_price", label: "판매단가", width: "w-[110px]", formatMoney: true },
|
|
{ key: "sales_supply_price", label: "판매공급가액", width: "w-[130px]", formatMoney: true },
|
|
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
|
|
{ key: "sales_total_amount", label: "판매총액", width: "w-[120px]", formatMoney: true },
|
|
{ key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[130px]", formatMoney: true },
|
|
{ key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[130px]", formatMoney: true },
|
|
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
|
|
{ key: "sales_status", label: "판매상태", width: "w-[100px]", align: "center" },
|
|
{ key: "production_status", label: "생산상태", width: "w-[100px]", align: "center" },
|
|
{ key: "shipping_order_status", label: "출하지시상태", width: "w-[110px]", align: "center" },
|
|
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
|
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
|
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
|
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
|
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
|
|
{ key: "product_no", label: "품번", width: "w-[120px]" },
|
|
/* wace salesMgmtList.jsp 503~519 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
|
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
|
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
|
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
|
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
|
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
|
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
|
|
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
|
|
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
|
|
*/
|
|
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
|
|
];
|
|
|
|
export default function SalesSalePage() {
|
|
const { user } = useAuth();
|
|
|
|
const [rows, setRows] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selected, setSelected] = useState<SaleListRow | null>(null);
|
|
// wace salesMgmtList.jsp 검색 폼: 1줄 7개 / 2줄 3개
|
|
const [searchForm, setSearchForm] = useState({
|
|
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
|
|
serialNo: "", shippingStatus: "", salesStatus: "",
|
|
orderDateFrom: "", orderDateTo: "",
|
|
shippingDateFrom: "", shippingDateTo: "",
|
|
});
|
|
|
|
const [registerOpen, setRegisterOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<SaleRegisterBody>({
|
|
project_no: "", sales_currency: "KRW", sales_exchange_rate: 1,
|
|
});
|
|
|
|
const fetchList = useCallback(async () => {
|
|
if (!user) return;
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
Object.entries(searchForm).forEach(([k, v]) => { if (v) params[k] = v; });
|
|
const data = await salesSaleApi.saleList(params);
|
|
const mapped = data.map((r, i) => ({ ...r, id: `${r.project_no}-${r.contract_item_objid}-${i}` }));
|
|
setRows(mapped);
|
|
} catch { toast.error("판매 목록 조회 실패"); }
|
|
finally { setLoading(false); }
|
|
}, [user, searchForm]);
|
|
|
|
useEffect(() => { fetchList(); }, [fetchList]);
|
|
|
|
const openRegister = () => {
|
|
if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; }
|
|
setForm({
|
|
project_no: selected.project_no,
|
|
shipping_order_status: selected.shipping_order_status ?? "PENDING",
|
|
serial_no: selected.serial_no ?? "",
|
|
sales_quantity: Number(selected.sales_quantity ?? selected.order_quantity ?? 0),
|
|
sales_unit_price: Number(selected.sales_unit_price ?? 0),
|
|
sales_supply_price: Number(selected.sales_supply_price ?? 0),
|
|
sales_vat: Number(selected.sales_vat ?? 0),
|
|
sales_total_amount: Number(selected.sales_total_amount ?? 0),
|
|
sales_currency: selected.sales_currency ?? "KRW",
|
|
sales_exchange_rate: Number(selected.sales_exchange_rate ?? 1),
|
|
shipping_date: selected.shipping_date ?? "",
|
|
shipping_method: selected.shipping_method ?? "",
|
|
manager_user_id: selected.manager_user_id ?? "",
|
|
incoterms: selected.incoterms ?? "",
|
|
has_split_shipment: selected.has_split_shipment ?? false,
|
|
});
|
|
setRegisterOpen(true);
|
|
};
|
|
|
|
const handleRegister = async () => {
|
|
setSaving(true);
|
|
try {
|
|
// 자동 계산
|
|
const qty = Number(form.sales_quantity ?? 0);
|
|
const price = Number(form.sales_unit_price ?? 0);
|
|
const supply = form.sales_supply_price && form.sales_supply_price > 0 ? form.sales_supply_price : qty * price;
|
|
const vat = form.sales_vat && form.sales_vat > 0 ? form.sales_vat : Math.round(supply * 0.1 * 100) / 100;
|
|
const total = form.sales_total_amount && form.sales_total_amount > 0 ? form.sales_total_amount : supply + vat;
|
|
await salesSaleApi.registerSale({ ...form, sales_supply_price: supply, sales_vat: vat, sales_total_amount: total });
|
|
toast.success("판매가 등록되었습니다.");
|
|
setRegisterOpen(false);
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
|
|
} finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">판매관리</h1>
|
|
<p className="text-sm text-muted-foreground">총 {rows.length}건 (라인 단위)</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
|
|
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}조회
|
|
</Button>
|
|
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openRegister} disabled={!selected}>
|
|
<Truck className="w-4 h-4 mr-1" />출하지시/판매등록
|
|
</Button>
|
|
<Button size="sm" variant="ghost"
|
|
onClick={() => setSearchForm({
|
|
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
|
|
serialNo: "", shippingStatus: "", salesStatus: "",
|
|
orderDateFrom: "", orderDateTo: "",
|
|
shippingDateFrom: "", shippingDateTo: "",
|
|
})}>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 폼 — wace 원본 salesMgmtList.jsp 재현 (1줄 7개 / 2줄 3개) */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">주문유형</Label>
|
|
<CommCodeSelect groupId="0000167"
|
|
value={searchForm.orderType}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">발주번호</Label>
|
|
<Input className="h-8 text-xs" placeholder="발주번호 검색"
|
|
value={searchForm.poNo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">고객사</Label>
|
|
<CustomerSelect
|
|
value={searchForm.customer_objid}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">품번</Label>
|
|
<PartSelect mode="partNo"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">품명</Label>
|
|
<PartSelect mode="partName"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
|
|
<Input className="h-8 text-xs"
|
|
value={searchForm.serialNo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">출하지시상태</Label>
|
|
<Select value={searchForm.shippingStatus || "all"}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, shippingStatus: v === "all" ? "" : v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="PENDING">대기</SelectItem>
|
|
<SelectItem value="COMPLETED">완료</SelectItem>
|
|
<SelectItem value="CANCELLED">취소</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{/* 2줄 */}
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">발주일</Label>
|
|
<div className="flex gap-0.5 items-center">
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateFrom}
|
|
onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} />
|
|
<span className="text-[11px] text-muted-foreground">~</span>
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateTo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">출하일</Label>
|
|
<div className="flex gap-0.5 items-center">
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateFrom}
|
|
onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} />
|
|
<span className="text-[11px] text-muted-foreground">~</span>
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateTo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">판매상태</Label>
|
|
<CommCodeSelect groupId="0900207"
|
|
value={searchForm.salesStatus}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, salesStatus: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
</div>
|
|
|
|
<DataGrid
|
|
columns={GRID_COLUMNS}
|
|
data={rows}
|
|
selectedId={selected ? `${selected.project_no}-${selected.contract_item_objid}-0` : null}
|
|
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
|
|
onRowDoubleClick={(row) => { setSelected(row); openRegister(); }}
|
|
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
|
loading={loading}
|
|
/>
|
|
|
|
{/* 출하지시/판매등록 Dialog */}
|
|
<Dialog open={registerOpen} onOpenChange={setRegisterOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>출하지시 / 판매 등록</DialogTitle>
|
|
<DialogDescription>{selected?.contract_no} · {selected?.product_name}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div><Label className="text-xs">판매수량</Label>
|
|
<Input type="number" value={form.sales_quantity ?? 0}
|
|
onChange={(e) => setForm({ ...form, sales_quantity: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">판매단가</Label>
|
|
<Input type="number" step="0.01" value={form.sales_unit_price ?? 0}
|
|
onChange={(e) => setForm({ ...form, sales_unit_price: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">통화</Label>
|
|
<Input value={form.sales_currency ?? "KRW"} onChange={(e) => setForm({ ...form, sales_currency: e.target.value })} /></div>
|
|
<div><Label className="text-xs">환율</Label>
|
|
<Input type="number" step="0.0001" value={form.sales_exchange_rate ?? 1}
|
|
onChange={(e) => setForm({ ...form, sales_exchange_rate: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">공급가액 (자동)</Label>
|
|
<Input type="number" step="0.01" value={form.sales_supply_price ?? 0}
|
|
onChange={(e) => setForm({ ...form, sales_supply_price: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">부가세 (자동 10%)</Label>
|
|
<Input type="number" step="0.01" value={form.sales_vat ?? 0}
|
|
onChange={(e) => setForm({ ...form, sales_vat: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">총액 (자동)</Label>
|
|
<Input type="number" step="0.01" value={form.sales_total_amount ?? 0}
|
|
onChange={(e) => setForm({ ...form, sales_total_amount: Number(e.target.value) })} /></div>
|
|
<div><Label className="text-xs">출하일</Label>
|
|
<Input type="date" value={form.shipping_date ?? ""} onChange={(e) => setForm({ ...form, shipping_date: e.target.value })} /></div>
|
|
<div><Label className="text-xs">출하방법</Label>
|
|
<Select value={form.shipping_method ?? ""} onValueChange={(v) => setForm({ ...form, shipping_method: v })}>
|
|
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="DIRECT">직배송</SelectItem>
|
|
<SelectItem value="PARCEL">택배</SelectItem>
|
|
</SelectContent>
|
|
</Select></div>
|
|
<div><Label className="text-xs">출하지시 상태</Label>
|
|
<Select value={form.shipping_order_status ?? "PENDING"} onValueChange={(v) => setForm({ ...form, shipping_order_status: v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="PENDING">대기</SelectItem>
|
|
<SelectItem value="COMPLETED">완료</SelectItem>
|
|
<SelectItem value="CANCELLED">취소</SelectItem>
|
|
</SelectContent>
|
|
</Select></div>
|
|
<div><Label className="text-xs">담당자 ID</Label>
|
|
<Input value={form.manager_user_id ?? ""} onChange={(e) => setForm({ ...form, manager_user_id: e.target.value })} /></div>
|
|
<div><Label className="text-xs">인도조건</Label>
|
|
<Input value={form.incoterms ?? ""} onChange={(e) => setForm({ ...form, incoterms: e.target.value })} placeholder="EXW/FOB/CIF" /></div>
|
|
<div className="col-span-3"><Label className="text-xs">시리얼 번호 (콤마 구분)</Label>
|
|
<Input value={form.serial_no ?? ""} onChange={(e) => setForm({ ...form, serial_no: e.target.value })} /></div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRegisterOpen(false)} disabled={saving}>취소</Button>
|
|
<Button onClick={handleRegister} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|