123
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>
|
||||
|
||||
@@ -971,6 +971,7 @@ export default function SupplierManagementPage() {
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
mappingRows = allMappings
|
||||
@@ -991,7 +992,8 @@ export default function SupplierManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
priceRows = allPriceData.map((p: any) => ({
|
||||
_id: `p_existing_${p.id}`,
|
||||
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
|
||||
@@ -1043,6 +1045,7 @@ export default function SupplierManagementPage() {
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
@@ -1092,7 +1095,8 @@ export default function SupplierManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 단가 upsert
|
||||
|
||||
@@ -988,6 +988,7 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
mappingRows = allMappings
|
||||
@@ -1008,7 +1009,8 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
priceRows = allPriceData.map((p: any) => ({
|
||||
_id: `p_existing_${p.id}`,
|
||||
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
|
||||
@@ -1063,6 +1065,7 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
@@ -1112,7 +1115,8 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 단가 upsert
|
||||
|
||||
Reference in New Issue
Block a user