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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user