주문서 수주통합등록 다이얼로그 wace G2 1:1 재작성

기존 RPS 폼이 wace estimateAndOrderRegistFormPopup과 크게 어긋남(헤더 항목 잘못 배치 + 라인 누락). 통째 재작성.

수주통합 기본정보 — wace 헤더 9개 정확 일치:
  영업번호(자동채번) / 주문유형* / 국내해외* / 고객사* / 유무상* / 접수일* / 견적환종 / 견적환율 / 발주번호 / 발주일*
  (이전 RPS의 요청납기/통화/수주상태/담당자(PM ID)/출하방법/고객사요청사항 헤더 배치 폐지 — wace 통합폼에 없음)

품목정보 — wace 라인 13컬럼 + Total 합계 행:
  No / 제품구분* / 품번* / 품명* / S/N / 요청납기 / 고객요청사항 / 반납사유 / 수주수량* / 수주단가 / 수주공급가액 / 수주부가세 / 수주총액 / 삭제
  (이전 누락: 제품구분 / S/N / 요청납기 / 고객요청사항 / 반납사유)

추가 컴포넌트:
- 품번/품명: PartSelect (part_mng 8,176건 검색 + 자동 매핑)
- 제품구분/반납사유: CommCodeSelect (0000001 / 0001810)
- S/N: 별도 다이얼로그(테이블 + 연속번호생성) — wace fn_openItemSnPopup / fn_openItemSequentialSnPopup 1:1
- 합계: useMemo로 라인 수량/공급가액/부가세/총액 실시간 집계

EMPTY_ITEM 라인 기본값에 product/serials/due_date/return_reason/customer_request 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 10:24:25 +09:00
parent 7c03907000
commit ae5ef8f7e5
@@ -70,8 +70,13 @@ const CONTRACT_RESULTS = [
{ value: "0000965", label: "Cancel" },
];
// wace estimateAndOrderRegistFormPopup 라인 — 제품구분/S/N/요청납기/반납사유/고객요청사항 포함
const EMPTY_ITEM: OrderItem = {
seq: 1, part_objid: "", part_no: "", part_name: "", quantity: 1,
seq: 1,
product: "", part_objid: "", part_no: "", part_name: "",
serials: [],
due_date: "", return_reason: "", customer_request: "",
quantity: 1,
order_quantity: "", order_unit_price: "0", order_supply_price: "0",
order_vat: "0", order_total_amount: "0",
};
@@ -98,8 +103,18 @@ export default function SalesOrderPage() {
items: [],
});
// 품목 검색 모달
// 품목 검색 모달 (라인별 진입)
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const [itemSearchTargetIdx, setItemSearchTargetIdx] = useState<number | null>(null);
// S/N 관리 모달 + 연속번호 생성 (wace fn_openItemSnPopup / fn_openItemSequentialSnPopup)
const [serialDialogOpen, setSerialDialogOpen] = useState(false);
const [serialDialogIdx, setSerialDialogIdx] = useState<number | null>(null);
const [serialDraft, setSerialDraft] = useState<string[]>([]);
const [serialInput, setSerialInput] = useState("");
const [seqDialogOpen, setSeqDialogOpen] = useState(false);
const [seqStartNo, setSeqStartNo] = useState("");
const [seqCount, setSeqCount] = useState("");
// 첨부파일 다이얼로그 (주문서첨부 클립 컬럼 클릭 시)
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
@@ -363,6 +378,63 @@ export default function SalesOrderPage() {
const addItem = () => setForm((prev) => ({ ...prev, items: [...(prev.items ?? []), { ...EMPTY_ITEM, seq: (prev.items?.length ?? 0) + 1 }] }));
const removeItem = (idx: number) => setForm((prev) => ({ ...prev, items: (prev.items ?? []).filter((_, i) => i !== idx).map((it, i) => ({ ...it, seq: i + 1 })) }));
// S/N 관리 (wace fn_openItemSnPopup) — 견적관리와 동일 패턴
const openSerialDialog = (idx: number) => {
const item = form.items?.[idx];
setSerialDialogIdx(idx);
setSerialDraft([...(item?.serials ?? [])]);
setSerialInput("");
setSerialDialogOpen(true);
};
const addSerialDraft = () => {
const v = serialInput.trim();
if (!v) { toast.warning("S/N을 입력해주세요."); return; }
if (serialDraft.includes(v)) { toast.warning("이미 등록된 S/N입니다."); return; }
setSerialDraft((prev) => [...prev, v]);
setSerialInput("");
};
const removeSerialDraft = (i: number) => setSerialDraft((prev) => prev.filter((_, k) => k !== i));
const applySerialDraft = () => {
if (serialDialogIdx === null) return;
updateItem(serialDialogIdx, "serials", [...serialDraft]);
setSerialDialogOpen(false);
};
const openSeqDialog = () => { setSeqStartNo(""); setSeqCount(""); setSeqDialogOpen(true); };
const generateSequentialSn = () => {
const startNo = seqStartNo.trim();
const count = parseInt(seqCount, 10);
if (!startNo) { toast.warning("시작 번호를 입력해주세요."); return; }
if (!count || count < 1) { toast.warning("생성 개수를 1 이상 입력해주세요."); return; }
if (count > 100) { toast.warning("최대 100개까지만 생성 가능합니다."); return; }
const m = startNo.match(/^(.*?)(\d+)$/);
if (!m) { toast.warning("형식이 올바르지 않습니다. 마지막에 숫자가 있어야 합니다."); return; }
const prefix = m[1]; const startNum = parseInt(m[2], 10); const numLen = m[2].length;
setSerialDraft((prev) => {
const next = [...prev];
for (let i = 0; i < count; i++) {
const sn = prefix + String(startNum + i).padStart(numLen, "0");
if (!next.includes(sn)) next.push(sn);
}
return next;
});
setSeqDialogOpen(false);
};
// 라인 합계 자동 계산용 헬퍼
const formatNum = (v: any) => {
const n = Number(String(v ?? "0").replace(/,/g, ""));
return isNaN(n) ? 0 : n;
};
const lineTotal = useMemo(() => {
const items = form.items ?? [];
return items.reduce((acc, it) => ({
qty: acc.qty + formatNum(it.order_quantity),
supply: acc.supply + formatNum(it.order_supply_price),
vat: acc.vat + formatNum(it.order_vat),
total: acc.total + formatNum(it.order_total_amount),
}), { qty: 0, supply: 0, vat: 0, total: 0 });
}, [form.items]);
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
{ConfirmDialogComponent}
@@ -489,107 +561,194 @@ export default function SalesOrderPage() {
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogContent className="!max-w-[95vw] w-[95vw] max-h-[92vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{dialogMode === "create" ? "주문서 등록" : "주문서 수정"}</DialogTitle>
<DialogDescription> + .</DialogDescription>
<DialogTitle> _ _ </DialogTitle>
<DialogDescription className="sr-only"> + . (wace estimateAndOrderRegistFormPopup 1:1)</DialogDescription>
</DialogHeader>
{/* 수주통합 기본정보 — wace 헤더 9개 (영업번호 자동채번 표시 포함) */}
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-semibold px-2"> </legend>
<legend className="text-sm font-semibold px-2"> </legend>
<div className="grid grid-cols-4 gap-3">
<div><Label className="text-xs"> ()</Label>
<Input readOnly className="bg-muted/30"
value={form.contract_no ?? ""}
placeholder="저장 시 자동 부여됩니다" /></div>
<div><Label className="text-xs"></Label>
<Input value={form.po_no ?? ""} onChange={(e) => setForm({ ...form, po_no: e.target.value })} /></div>
<div><Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.order_date ?? ""} onChange={(e) => setForm({ ...form, order_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.req_del_date ?? ""} onChange={(e) => setForm({ ...form, req_del_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<CustomerSelect
value={form.customer_objid ?? ""}
onValueChange={(v) => setForm({ ...form, customer_objid: v })}
/></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.receipt_date ?? ""} onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.contract_currency ?? "KRW"} onChange={(e) => setForm({ ...form, contract_currency: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.exchange_rate ?? ""} onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} /></div>
<div><Label className="text-xs">/</Label>
<Select value={form.paid_type ?? "paid"} onValueChange={(v) => setForm({ ...form, paid_type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<div>
<Label className="text-xs"> ()</Label>
<Input readOnly className="bg-muted/30" value={form.contract_no ?? ""} placeholder="저장 시 자동 부여" />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<CommCodeSelect groupId="0000167" value={form.category_cd ?? ""}
onValueChange={(v) => setForm({ ...form, category_cd: v })} />
</div>
<div>
<Label className="text-xs">/ <span className="text-rose-600">*</span></Label>
<CommCodeSelect groupId="0001219" value={form.area_cd ?? ""}
onValueChange={(v) => setForm({ ...form, area_cd: v })} />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<CustomerSelect value={form.customer_objid ?? ""}
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
</div>
<div>
<Label className="text-xs">/ <span className="text-rose-600">*</span></Label>
<Select value={form.paid_type || undefined}
onValueChange={(v) => setForm({ ...form, paid_type: v })}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="paid"></SelectItem>
<SelectItem value="free"></SelectItem>
</SelectContent>
</Select></div>
<div><Label className="text-xs"></Label>
<Select value={form.contract_result || undefined} onValueChange={(v) => setForm({ ...form, contract_result: v })}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
</SelectContent>
</Select></div>
<div><Label className="text-xs">(PM ID)</Label>
<Input value={form.pm_user_id ?? ""} onChange={(e) => setForm({ ...form, pm_user_id: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.shipping_method ?? ""} onChange={(e) => setForm({ ...form, shipping_method: e.target.value })} /></div>
<div className="col-span-4"><Label className="text-xs"> </Label>
<Textarea rows={2} value={form.customer_request ?? ""} onChange={(e) => setForm({ ...form, customer_request: e.target.value })} /></div>
</Select>
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.receipt_date ?? ""}
onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<CommCodeSelect groupId="0001533" value={form.contract_currency ?? ""}
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.exchange_rate ?? ""}
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={form.po_no ?? ""}
onChange={(e) => setForm({ ...form, po_no: e.target.value })} />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="date" value={form.order_date ?? ""}
onChange={(e) => setForm({ ...form, order_date: e.target.value })} />
</div>
</div>
</fieldset>
{/* 품목정보 — wace 13컬럼 + Total 합계 행 */}
<fieldset className="border rounded-md p-3 space-y-2">
<legend className="text-sm font-semibold px-2"> </legend>
<legend className="text-sm font-semibold px-2 flex items-center justify-between w-full">
<span></span>
</legend>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="p-2 w-10">#</th>
<th className="p-2 w-32"> ID</th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-24"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-28"></th>
<th className="p-2 w-10"></th>
<th className="p-2 w-10 whitespace-nowrap">No</th>
<th className="p-2 w-28 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-40 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-56 whitespace-nowrap"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-40 whitespace-nowrap">S/N</th>
<th className="p-2 w-36 whitespace-nowrap"></th>
<th className="p-2 min-w-[220px] whitespace-nowrap"></th>
<th className="p-2 w-28 whitespace-nowrap"></th>
<th className="p-2 w-24 whitespace-nowrap text-right"> <span className="text-rose-600">*</span></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-28 whitespace-nowrap text-right"></th>
<th className="p-2 w-32 whitespace-nowrap text-right"></th>
<th className="p-2 w-12 whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{(form.items ?? []).map((it, idx) => (
<tr key={idx} className="border-t">
<td className="p-1 text-center">{it.seq}</td>
<td className="p-1"><Input className="h-8" value={it.part_objid} onChange={(e) => updateItem(idx, "part_objid", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" value={it.part_no} onChange={(e) => updateItem(idx, "part_no", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" value={it.part_name} onChange={(e) => updateItem(idx, "part_name", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_quantity ?? ""} onChange={(e) => updateItem(idx, "order_quantity", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""} onChange={(e) => updateItem(idx, "order_unit_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""} onChange={(e) => updateItem(idx, "order_supply_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""} onChange={(e) => updateItem(idx, "order_vat", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""} onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
<td className="p-1"><Input className="h-8" type="date" value={it.due_date ?? ""} onChange={(e) => updateItem(idx, "due_date", e.target.value)} /></td>
<td className="p-1 text-center"><Button variant="ghost" size="icon" onClick={() => removeItem(idx)}><Trash2 className="w-3 h-3" /></Button></td>
<td className="p-1">
<CommCodeSelect groupId="0000001"
value={it.product ?? ""}
onValueChange={(v) => updateItem(idx, "product", v)} />
</td>
<td className="p-1">
<PartSelect mode="partNo" value={it.part_objid}
onValueChange={(partObjId, row) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = { ...items[idx], part_objid: partObjId,
part_no: row?.item_number ?? "",
part_name: row?.item_name ?? items[idx].part_name };
return { ...prev, items };
});
}} />
</td>
<td className="p-1">
<PartSelect mode="partName" value={it.part_objid}
onValueChange={(partObjId, row) => {
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = { ...items[idx], part_objid: partObjId,
part_no: row?.item_number ?? items[idx].part_no,
part_name: row?.item_name ?? "" };
return { ...prev, items };
});
}} />
</td>
<td className="p-1">
<Input className="h-8" readOnly
value={(it.serials ?? []).join(", ")}
onClick={() => openSerialDialog(idx)}
placeholder="클릭하여 S/N 추가" />
</td>
<td className="p-1">
<Input className="h-8" type="date" value={it.due_date ?? ""}
onChange={(e) => updateItem(idx, "due_date", e.target.value)} />
</td>
<td className="p-1">
<Textarea className="min-h-[34px] resize-y text-xs" rows={1}
value={it.customer_request ?? ""}
onChange={(e) => updateItem(idx, "customer_request", e.target.value)} />
</td>
<td className="p-1">
<CommCodeSelect groupId="0001810"
value={it.return_reason ?? ""}
onValueChange={(v) => updateItem(idx, "return_reason", v)} />
</td>
<td className="p-1">
<Input className="h-8 text-right" type="number" min={0}
value={it.order_quantity ?? ""}
onChange={(e) => updateItem(idx, "order_quantity", e.target.value)} />
</td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""}
onChange={(e) => updateItem(idx, "order_unit_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""}
onChange={(e) => updateItem(idx, "order_supply_price", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""}
onChange={(e) => updateItem(idx, "order_vat", e.target.value)} /></td>
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""}
onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
<td className="p-1 text-center">
<Button variant="ghost" size="icon" onClick={() => removeItem(idx)}>
<Trash2 className="w-3 h-3" />
</Button>
</td>
</tr>
))}
{(!form.items || form.items.length === 0) && (
<tr><td colSpan={11} className="p-3 text-center text-muted-foreground"> .</td></tr>
<tr><td colSpan={14} className="p-3 text-center text-muted-foreground"> .</td></tr>
)}
</tbody>
{(form.items?.length ?? 0) > 0 && (
<tfoot>
<tr className="bg-muted/30 font-semibold">
<td colSpan={8} className="p-2 text-center">Total</td>
<td className="p-2 text-right">{lineTotal.qty.toLocaleString()}</td>
<td className="p-2"></td>
<td className="p-2 text-right">{lineTotal.supply.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.vat.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2 text-right">{lineTotal.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="p-2"></td>
</tr>
</tfoot>
)}
</table>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => setItemDialogOpen(true)}>
<Search className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
</fieldset>
@@ -603,6 +762,82 @@ export default function SalesOrderPage() {
</DialogContent>
</Dialog>
{/* S/N 관리 — wace fn_openItemSnPopup */}
<Dialog open={serialDialogOpen} onOpenChange={setSerialDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-center">S/N </DialogTitle>
<DialogDescription className="sr-only"> / </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="max-h-[300px] overflow-y-auto border rounded">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border px-3 py-2 w-16 text-center"></th>
<th className="border px-3 py-2 text-center">S/N</th>
<th className="border px-3 py-2 w-20 text-center"></th>
</tr>
</thead>
<tbody>
{serialDraft.length === 0 ? (
<tr><td colSpan={3} className="border px-3 py-8 text-center text-muted-foreground"> S/N이 .</td></tr>
) : serialDraft.map((s, i) => (
<tr key={i}>
<td className="border px-3 py-1.5 text-center">{i + 1}</td>
<td className="border px-3 py-1.5">{s}</td>
<td className="border px-3 py-1.5 text-center">
<Button size="sm" variant="outline" onClick={() => removeSerialDraft(i)}></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Input value={serialInput} onChange={(e) => setSerialInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSerialDraft(); } }}
placeholder="S/N 입력" />
<Button onClick={addSerialDraft} type="button"></Button>
</div>
</div>
<DialogFooter className="sm:justify-center">
<Button variant="outline" onClick={openSeqDialog}></Button>
<Button onClick={applySerialDraft}></Button>
<Button variant="outline" onClick={() => setSerialDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 연속번호 생성 — wace fn_openItemSequentialSnPopup */}
<Dialog open={seqDialogOpen} onOpenChange={setSeqDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> S/N </DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input value={seqStartNo} onChange={(e) => setSeqStartNo(e.target.value)} placeholder="예: ITEM-001" />
</div>
<div>
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<Input type="number" min={1} max={100} value={seqCount}
onChange={(e) => setSeqCount(e.target.value)} placeholder="예: 10" />
</div>
<div className="bg-muted/40 rounded p-2 text-[11px] leading-5 text-muted-foreground">
: ITEM-001, 3 ITEM-001, ITEM-002, ITEM-003<br />
100
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSeqDialogOpen(false)}></Button>
<Button onClick={generateSequentialSn}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 첨부파일 다이얼로그 — 주문서첨부 클립 컬럼 클릭 시 */}
{attachContext && (
<AttachmentDialog