feat: Implement drag-and-drop functionality for modal column reordering in purchase order page

- Added DnD (Drag and Drop) capabilities to allow users to reorder columns in the modal for purchase orders.
- Introduced a new `SortableModalHead` component to manage the sortable headers.
- Implemented local storage functionality to save and retrieve the column order, enhancing user customization.
- This feature aims to improve the user experience by providing flexibility in how data is displayed across multiple company implementations.
This commit is contained in:
kjs
2026-04-13 10:55:11 +09:00
parent 2776437702
commit b28e8e206c
6 changed files with 1047 additions and 544 deletions
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown, ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" }, { 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() { export default function PurchaseOrderPage() {
const { user } = useAuth(); const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정 // 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); 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 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(() => { useEffect(() => {
const loadCategories = async () => { const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-x-auto"> <div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed"> <DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<TableHeader className="sticky top-0 z-10"> <Table className="table-fixed">
<TableRow className="bg-muted hover:bg-muted"> <TableHeader className="sticky top-0 z-10">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{masterForm.input_mode === "itemFirst" && ( {visibleModalColumns.map((col) => (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <SortableModalHead key={col.key} col={col} />
)} ))}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableRow>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </SortableContext>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableHeader>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableBody>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {detailRows.map((row, idx) => (
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow key={row._id || idx}>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && (
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableCell>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <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)}>
</TableRow> <X className="w-3.5 h-3.5" />
</TableHeader> </Button>
<TableBody> </TableCell>
{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" />
)} )}
</TableCell> {visibleModalColumns.map((col) => {
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell> switch (col.key) {
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell> case "item_code":
<TableCell> 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>;
{isReadOnly ? ( case "item_name":
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span> 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":
<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" /> return (
)} <TableCell key={col.key}>
</TableCell> {isReadOnly ? (
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell> <span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
<TableCell> ) : (
{isReadOnly ? ( <Select value={row.supplier_code || ""} onValueChange={(v) => {
<span className="text-xs">{row.due_date}</span> const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
) : ( const name = supp?.label.replace(` (${v})`, "") || "";
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> updateDetailRow(idx, "supplier_code", v);
)} updateDetailRow(idx, "supplier_name", name);
</TableCell> }}>
<TableCell> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
{isReadOnly ? ( <SelectContent>
<span className="text-xs">{row.memo}</span> {(categoryOptions["supplier_code"] || []).map(o => (
) : ( <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> ))}
)} </SelectContent>
</TableCell> </Select>
</TableRow> )}
))} </TableCell>
</TableBody> );
</Table> 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>
)} )}
</div> </div>
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown, ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" }, { 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() { export default function PurchaseOrderPage() {
const { user } = useAuth(); const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정 // 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); 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 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(() => { useEffect(() => {
const loadCategories = async () => { const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-x-auto"> <div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed"> <DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<TableHeader className="sticky top-0 z-10"> <Table className="table-fixed">
<TableRow className="bg-muted hover:bg-muted"> <TableHeader className="sticky top-0 z-10">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{masterForm.input_mode === "itemFirst" && ( {visibleModalColumns.map((col) => (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <SortableModalHead key={col.key} col={col} />
)} ))}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableRow>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </SortableContext>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableHeader>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableBody>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {detailRows.map((row, idx) => (
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow key={row._id || idx}>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && (
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableCell>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <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)}>
</TableRow> <X className="w-3.5 h-3.5" />
</TableHeader> </Button>
<TableBody> </TableCell>
{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" />
)} )}
</TableCell> {visibleModalColumns.map((col) => {
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell> switch (col.key) {
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell> case "item_code":
<TableCell> 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>;
{isReadOnly ? ( case "item_name":
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span> 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":
<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" /> return (
)} <TableCell key={col.key}>
</TableCell> {isReadOnly ? (
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell> <span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
<TableCell> ) : (
{isReadOnly ? ( <Select value={row.supplier_code || ""} onValueChange={(v) => {
<span className="text-xs">{row.due_date}</span> const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
) : ( const name = supp?.label.replace(` (${v})`, "") || "";
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> updateDetailRow(idx, "supplier_code", v);
)} updateDetailRow(idx, "supplier_name", name);
</TableCell> }}>
<TableCell> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
{isReadOnly ? ( <SelectContent>
<span className="text-xs">{row.memo}</span> {(categoryOptions["supplier_code"] || []).map(o => (
) : ( <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> ))}
)} </SelectContent>
</TableCell> </Select>
</TableRow> )}
))} </TableCell>
</TableBody> );
</Table> 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>
)} )}
</div> </div>
@@ -18,6 +18,9 @@ import {
Settings2, GripVertical, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -28,11 +31,6 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; 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 MASTER_TABLE = "purchase_order_mng";
const DETAIL_TABLE = "purchase_detail"; const DETAIL_TABLE = "purchase_detail";
@@ -104,7 +102,7 @@ const MODAL_DETAIL_COLUMNS = [
{ key: "memo", label: "메모", width: "w-[120px]" }, { 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 } }) { function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key }); 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, opacity: isDragging ? 0.5 : 1,
}; };
return ( 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 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.5 w-3.5 text-muted-foreground/70 shrink-0" />
<GripVertical className="h-3 w-3" />
</div>
<span className="truncate">{col.label}</span> <span className="truncate">{col.label}</span>
</div> </div>
</TableHead> </TableHead>
@@ -748,89 +753,6 @@ export default function PurchaseOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx)); 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 () => { const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; } if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; }
const data = orders.map((o) => { const data = orders.map((o) => {
@@ -1193,7 +1115,88 @@ export default function PurchaseOrderPage() {
</Button> </Button>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown, ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" }, { 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() { export default function PurchaseOrderPage() {
const { user } = useAuth(); const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정 // 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); 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 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(() => { useEffect(() => {
const loadCategories = async () => { const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-x-auto"> <div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed"> <DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<TableHeader className="sticky top-0 z-10"> <Table className="table-fixed">
<TableRow className="bg-muted hover:bg-muted"> <TableHeader className="sticky top-0 z-10">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{masterForm.input_mode === "itemFirst" && ( {visibleModalColumns.map((col) => (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <SortableModalHead key={col.key} col={col} />
)} ))}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableRow>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </SortableContext>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableHeader>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableBody>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {detailRows.map((row, idx) => (
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow key={row._id || idx}>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && (
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableCell>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <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)}>
</TableRow> <X className="w-3.5 h-3.5" />
</TableHeader> </Button>
<TableBody> </TableCell>
{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" />
)} )}
</TableCell> {visibleModalColumns.map((col) => {
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell> switch (col.key) {
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell> case "item_code":
<TableCell> 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>;
{isReadOnly ? ( case "item_name":
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span> 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":
<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" /> return (
)} <TableCell key={col.key}>
</TableCell> {isReadOnly ? (
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell> <span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
<TableCell> ) : (
{isReadOnly ? ( <Select value={row.supplier_code || ""} onValueChange={(v) => {
<span className="text-xs">{row.due_date}</span> const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
) : ( const name = supp?.label.replace(` (${v})`, "") || "";
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> updateDetailRow(idx, "supplier_code", v);
)} updateDetailRow(idx, "supplier_name", name);
</TableCell> }}>
<TableCell> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
{isReadOnly ? ( <SelectContent>
<span className="text-xs">{row.memo}</span> {(categoryOptions["supplier_code"] || []).map(o => (
) : ( <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> ))}
)} </SelectContent>
</TableCell> </Select>
</TableRow> )}
))} </TableCell>
</TableBody> );
</Table> 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>
)} )}
</div> </div>
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown, ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" }, { 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() { export default function PurchaseOrderPage() {
const { user } = useAuth(); const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정 // 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); 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 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(() => { useEffect(() => {
const loadCategories = async () => { const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-x-auto"> <div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed"> <DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<TableHeader className="sticky top-0 z-10"> <Table className="table-fixed">
<TableRow className="bg-muted hover:bg-muted"> <TableHeader className="sticky top-0 z-10">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{masterForm.input_mode === "itemFirst" && ( {visibleModalColumns.map((col) => (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <SortableModalHead key={col.key} col={col} />
)} ))}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableRow>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </SortableContext>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableHeader>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableBody>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {detailRows.map((row, idx) => (
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow key={row._id || idx}>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && (
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableCell>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <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)}>
</TableRow> <X className="w-3.5 h-3.5" />
</TableHeader> </Button>
<TableBody> </TableCell>
{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" />
)} )}
</TableCell> {visibleModalColumns.map((col) => {
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell> switch (col.key) {
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell> case "item_code":
<TableCell> 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>;
{isReadOnly ? ( case "item_name":
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span> 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":
<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" /> return (
)} <TableCell key={col.key}>
</TableCell> {isReadOnly ? (
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell> <span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
<TableCell> ) : (
{isReadOnly ? ( <Select value={row.supplier_code || ""} onValueChange={(v) => {
<span className="text-xs">{row.due_date}</span> const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
) : ( const name = supp?.label.replace(` (${v})`, "") || "";
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> updateDetailRow(idx, "supplier_code", v);
)} updateDetailRow(idx, "supplier_name", name);
</TableCell> }}>
<TableCell> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
{isReadOnly ? ( <SelectContent>
<span className="text-xs">{row.memo}</span> {(categoryOptions["supplier_code"] || []).map(o => (
) : ( <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> ))}
)} </SelectContent>
</TableCell> </Select>
</TableRow> )}
))} </TableCell>
</TableBody> );
</Table> 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>
)} )}
</div> </div>
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown, ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, Settings2, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" }, { 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() { export default function PurchaseOrderPage() {
const { user } = useAuth(); const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정 // 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); 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 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(() => { useEffect(() => {
const loadCategories = async () => { const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-x-auto"> <div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed"> <DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<TableHeader className="sticky top-0 z-10"> <Table className="table-fixed">
<TableRow className="bg-muted hover:bg-muted"> <TableHeader className="sticky top-0 z-10">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{masterForm.input_mode === "itemFirst" && ( {visibleModalColumns.map((col) => (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <SortableModalHead key={col.key} col={col} />
)} ))}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableRow>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </SortableContext>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> </TableHeader>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableBody>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {detailRows.map((row, idx) => (
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableRow key={row._id || idx}>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> {!isReadOnly && (
<TableHead className="w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <TableCell>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead> <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)}>
</TableRow> <X className="w-3.5 h-3.5" />
</TableHeader> </Button>
<TableBody> </TableCell>
{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" />
)} )}
</TableCell> {visibleModalColumns.map((col) => {
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell> switch (col.key) {
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell> case "item_code":
<TableCell> 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>;
{isReadOnly ? ( case "item_name":
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span> 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":
<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" /> return (
)} <TableCell key={col.key}>
</TableCell> {isReadOnly ? (
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell> <span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
<TableCell> ) : (
{isReadOnly ? ( <Select value={row.supplier_code || ""} onValueChange={(v) => {
<span className="text-xs">{row.due_date}</span> const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
) : ( const name = supp?.label.replace(` (${v})`, "") || "";
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" /> updateDetailRow(idx, "supplier_code", v);
)} updateDetailRow(idx, "supplier_name", name);
</TableCell> }}>
<TableCell> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
{isReadOnly ? ( <SelectContent>
<span className="text-xs">{row.memo}</span> {(categoryOptions["supplier_code"] || []).map(o => (
) : ( <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" /> ))}
)} </SelectContent>
</TableCell> </Select>
</TableRow> )}
))} </TableCell>
</TableBody> );
</Table> 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>
)} )}
</div> </div>