Merge pull request 'jskim-node' (#25) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/25
This commit is contained in:
@@ -15,9 +15,12 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "memo", label: "메모" },
|
||||
];
|
||||
|
||||
const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "w-[120px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
|
||||
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
|
||||
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[160px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
||||
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
||||
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
||||
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
||||
setModalColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setModalColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
||||
|
||||
const visibleModalColumns = useMemo(() => {
|
||||
return modalColumns.filter((col) => {
|
||||
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
||||
return true;
|
||||
});
|
||||
}, [modalColumns, masterForm.input_mode]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
|
||||
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
{visibleModalColumns.map((col) => (
|
||||
<SortableModalHead key={col.key} col={col} />
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (마스터 단위)
|
||||
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 1. 선택한 디테일 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
for (const orderNo of orderNos) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
|
||||
if (remaining.length === 0) {
|
||||
// 디테일 0건 → 마스터 삭제
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => {
|
||||
const next = { ...p, input_mode: v };
|
||||
// 입력방식 변경 시 거래처 관련 값 초기화
|
||||
delete next.partner_id;
|
||||
delete next.delivery_partner_id;
|
||||
delete next.delivery_address;
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (마스터 단위)
|
||||
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 1. 선택한 디테일 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
for (const orderNo of orderNos) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
|
||||
if (remaining.length === 0) {
|
||||
// 디테일 0건 → 마스터 삭제
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => {
|
||||
const next = { ...p, input_mode: v };
|
||||
// 입력방식 변경 시 거래처 관련 값 초기화
|
||||
delete next.partner_id;
|
||||
delete next.delivery_partner_id;
|
||||
delete next.delivery_address;
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "memo", label: "메모" },
|
||||
];
|
||||
|
||||
const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "w-[120px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
|
||||
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
|
||||
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[160px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
||||
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
||||
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
||||
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
||||
setModalColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setModalColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
||||
|
||||
const visibleModalColumns = useMemo(() => {
|
||||
return modalColumns.filter((col) => {
|
||||
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
||||
return true;
|
||||
});
|
||||
}, [modalColumns, masterForm.input_mode]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
|
||||
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
{visibleModalColumns.map((col) => (
|
||||
<SortableModalHead key={col.key} col={col} />
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (마스터 단위)
|
||||
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 1. 선택한 디테일 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
for (const orderNo of orderNos) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
|
||||
if (remaining.length === 0) {
|
||||
// 디테일 0건 → 마스터 삭제
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => {
|
||||
const next = { ...p, input_mode: v };
|
||||
// 입력방식 변경 시 거래처 관련 값 초기화
|
||||
delete next.partner_id;
|
||||
delete next.delivery_partner_id;
|
||||
delete next.delivery_address;
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -28,11 +31,6 @@ import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
const MASTER_TABLE = "purchase_order_mng";
|
||||
const DETAIL_TABLE = "purchase_detail";
|
||||
@@ -104,7 +102,7 @@ const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order";
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
@@ -114,11 +112,18 @@ function SortableModalHead({ col }: { col: { key: string; label: string; width:
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead ref={setNodeRef} style={style} className={cn(col.width, "text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none")}>
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div {...attributes} {...listeners} className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
@@ -748,89 +753,6 @@ export default function PurchaseOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const renderDetailCell = (col: { key: string }, row: any, idx: number) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; }
|
||||
const data = orders.map((o) => {
|
||||
@@ -1193,7 +1115,88 @@ export default function PurchaseOrderPage() {
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleModalColumns.map((col) => renderDetailCell(col, row, idx))}
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,12 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "memo", label: "메모" },
|
||||
];
|
||||
|
||||
const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "w-[120px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
|
||||
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
|
||||
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[160px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
||||
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
||||
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
||||
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
||||
setModalColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setModalColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
||||
|
||||
const visibleModalColumns = useMemo(() => {
|
||||
return modalColumns.filter((col) => {
|
||||
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
||||
return true;
|
||||
});
|
||||
}, [modalColumns, masterForm.input_mode]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
|
||||
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
{visibleModalColumns.map((col) => (
|
||||
<SortableModalHead key={col.key} col={col} />
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (마스터 단위)
|
||||
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 1. 선택한 디테일 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
for (const orderNo of orderNos) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
|
||||
if (remaining.length === 0) {
|
||||
// 디테일 0건 → 마스터 삭제
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "memo", label: "메모" },
|
||||
];
|
||||
|
||||
const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "w-[120px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
|
||||
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
|
||||
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[160px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
||||
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
||||
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
||||
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
||||
setModalColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setModalColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
||||
|
||||
const visibleModalColumns = useMemo(() => {
|
||||
return modalColumns.filter((col) => {
|
||||
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
||||
return true;
|
||||
});
|
||||
}, [modalColumns, masterForm.input_mode]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
|
||||
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
{visibleModalColumns.map((col) => (
|
||||
<SortableModalHead key={col.key} col={col} />
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (마스터 단위)
|
||||
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 1. 선택한 디테일 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
for (const orderNo of orderNos) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
|
||||
if (remaining.length === 0) {
|
||||
// 디테일 0건 → 마스터 삭제
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
|
||||
{/* 데이터 테이블 (플랫 리스트) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<Table noWrapper style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => {
|
||||
const next = { ...p, input_mode: v };
|
||||
// 입력방식 변경 시 거래처 관련 값 초기화
|
||||
delete next.partner_id;
|
||||
delete next.delivery_partner_id;
|
||||
delete next.delivery_address;
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
Settings2, GripVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "memo", label: "메모" },
|
||||
];
|
||||
|
||||
const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "w-[120px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
|
||||
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
|
||||
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[160px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
|
||||
|
||||
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
col.width,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
||||
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
||||
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
||||
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
||||
setModalColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setModalColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
||||
|
||||
const visibleModalColumns = useMemo(() => {
|
||||
return modalColumns.filter((col) => {
|
||||
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
||||
return true;
|
||||
});
|
||||
}, [modalColumns, masterForm.input_mode]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
|
||||
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
|
||||
{masterForm.input_mode === "itemFirst" && (
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
||||
{visibleModalColumns.map((col) => (
|
||||
<SortableModalHead key={col.key} col={col} />
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
{!isReadOnly && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleModalColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "item_code":
|
||||
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
||||
case "item_name":
|
||||
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
||||
case "supplier":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
||||
const name = supp?.label.replace(` (${v})`, "") || "";
|
||||
updateDetailRow(idx, "supplier_code", v);
|
||||
updateDetailRow(idx, "supplier_name", name);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "received_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.due_date}</span>
|
||||
) : (
|
||||
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "memo":
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs">{row.memo}</span>
|
||||
) : (
|
||||
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return <TableCell key={col.key} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처담당자</Label>
|
||||
<Input
|
||||
value={customerForm.contact_person || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
|
||||
placeholder="거래처담당자"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.contact_phone || ""}
|
||||
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user