b28e8e206c
- 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.
1388 lines
69 KiB
TypeScript
1388 lines
69 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
|
ClipboardList, Pencil, Search, X, Package, ChevronDown,
|
|
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
|
Settings2, GripVertical,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
|
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const MASTER_TABLE = "purchase_order_mng";
|
|
const DETAIL_TABLE = "purchase_detail";
|
|
|
|
const formatNumber = (val: string) => {
|
|
const num = val.replace(/[^\d.-]/g, "");
|
|
if (!num) return "";
|
|
const parts = num.split(".");
|
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
return parts.join(".");
|
|
};
|
|
const parseNumber = (val: string) => val.replace(/,/g, "");
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ code: "작성중", label: "작성중" },
|
|
{ code: "발주확정", label: "발주확정" },
|
|
{ code: "입고완료", label: "입고완료" },
|
|
{ code: "취소", label: "취소" },
|
|
];
|
|
|
|
const STATUS_BADGE_CLASS: Record<string, string> = {
|
|
"작성중": "bg-warning/10 text-warning border-warning/20",
|
|
"발주확정": "bg-primary/10 text-primary border-primary/20",
|
|
"입고완료": "bg-success/10 text-success border-success/20",
|
|
"취소": "bg-destructive/10 text-destructive border-destructive/20",
|
|
};
|
|
|
|
const EXCEL_COLUMNS = [
|
|
{ key: "purchase_no", label: "발주번호" },
|
|
{ key: "order_date", label: "발주일" },
|
|
{ key: "supplier_name", label: "공급업체명" },
|
|
{ key: "item_code", label: "품번" },
|
|
{ key: "item_name", label: "품명" },
|
|
{ key: "order_qty", label: "발주수량" },
|
|
{ key: "unit_price", label: "단가" },
|
|
{ key: "amount", label: "금액" },
|
|
{ key: "due_date", label: "납기일" },
|
|
];
|
|
|
|
const GRID_COLUMNS_CONFIG = [
|
|
{ key: "purchase_no", label: "발주번호" },
|
|
{ key: "order_date", label: "발주일" },
|
|
{ key: "supplier_name", label: "공급업체" },
|
|
{ key: "item_code", label: "품번" },
|
|
{ key: "item_name", label: "품명" },
|
|
{ key: "spec", label: "규격" },
|
|
{ key: "order_qty", label: "발주수량" },
|
|
{ key: "received_qty", label: "입고수량" },
|
|
{ key: "remain_qty", label: "잔량" },
|
|
{ key: "unit_price", label: "단가" },
|
|
{ key: "amount", label: "금액" },
|
|
{ key: "due_date", label: "납기일" },
|
|
{ key: "status", 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() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const [orders, setOrders] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
|
|
// 검색 필터 (DynamicSearchFilter)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
|
const [detailRows, setDetailRows] = useState<any[]>([]);
|
|
|
|
// 품목 선택 모달
|
|
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
|
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
|
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
|
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
|
|
const [itemSearchDivision, setItemSearchDivision] = useState("all");
|
|
const [itemPage, setItemPage] = useState(1);
|
|
const [itemPageSize, setItemPageSize] = useState(20);
|
|
const [itemTotalPages, setItemTotalPages] = useState(0);
|
|
const [itemTotal, setItemTotal] = useState(0);
|
|
const [itemPageInput, setItemPageInput] = useState("1");
|
|
|
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
|
|
|
// 테이블 설정
|
|
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
|
|
|
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
|
|
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
|
|
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
|
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
|
|
if (saved) {
|
|
try {
|
|
const order = JSON.parse(saved) as string[];
|
|
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
|
|
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
|
|
setModalColumns([...reordered, ...remaining]);
|
|
} catch { /* skip */ }
|
|
}
|
|
}, []);
|
|
|
|
const handleModalDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
setModalColumns((prev) => {
|
|
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
|
const newIndex = prev.findIndex((c) => c.key === over.id);
|
|
const next = arrayMove(prev, oldIndex, newIndex);
|
|
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
|
|
|
|
const visibleModalColumns = useMemo(() => {
|
|
return modalColumns.filter((col) => {
|
|
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
|
|
return true;
|
|
});
|
|
}, [modalColumns, masterForm.input_mode]);
|
|
|
|
// 카테고리 로드
|
|
useEffect(() => {
|
|
const loadCategories = async () => {
|
|
const catColumns = ["input_mode", "price_mode"];
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of vals) {
|
|
result.push({ code: v.valueCode, label: v.valueLabel });
|
|
if (v.children?.length) result.push(...flatten(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
const dedup = (items: { code: string; label: string }[]) => {
|
|
const seen = new Set<string>();
|
|
return items.filter((item) => {
|
|
const key = item.label.replace(/\s/g, "");
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
await Promise.all(
|
|
catColumns.map(async (col) => {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
|
|
if (res.data?.success && res.data.data?.length > 0) {
|
|
optMap[col] = dedup(flatten(res.data.data));
|
|
}
|
|
} catch { /* skip */ }
|
|
})
|
|
);
|
|
try {
|
|
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
});
|
|
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
|
|
optMap["supplier_code"] = supps.map((s: any) => ({
|
|
code: s.supplier_code,
|
|
label: `${s.supplier_name} (${s.supplier_code})`,
|
|
}));
|
|
} catch { /* skip */ }
|
|
try {
|
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
});
|
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
|
optMap["manager"] = users.map((u: any) => ({
|
|
code: u.user_id || u.id,
|
|
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
|
|
}));
|
|
} catch { /* skip */ }
|
|
for (const col of ["unit", "material", "division"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
|
if (res.data?.success && res.data.data?.length > 0) {
|
|
optMap[`item_${col}`] = flatten(res.data.data);
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
setCategoryOptions(optMap);
|
|
const divs = optMap["item_division"] || [];
|
|
const purchaseDiv = divs.find((o) => o.label === "구매관리")
|
|
|| divs.find((o) => o.label === "원자재")
|
|
|| divs.find((o) => o.label === "부자재");
|
|
if (purchaseDiv) setItemSearchDivision(purchaseDiv.code);
|
|
};
|
|
loadCategories();
|
|
}, []);
|
|
|
|
// 마스터 테이블 컬럼 (supplier_name, order_date 등)
|
|
const MASTER_COLUMNS = new Set(["supplier_name", "supplier_code", "order_date", "status"]);
|
|
|
|
// 데이터 조회
|
|
const fetchOrders = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
// searchFilters를 detail / master로 분리
|
|
const detailFilters: any[] = [];
|
|
const masterExtraFilters: any[] = [];
|
|
for (const f of searchFilters) {
|
|
const filter = { columnName: f.columnName, operator: f.operator, value: f.value };
|
|
if (MASTER_COLUMNS.has(f.columnName)) {
|
|
masterExtraFilters.push(filter);
|
|
} else {
|
|
detailFilters.push(filter);
|
|
}
|
|
}
|
|
|
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
|
|
autoFilter: true,
|
|
sort: { columnName: "purchase_no", order: "desc" },
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
|
|
const purchaseNos = [...new Set(rows.map((r: any) => r.purchase_no).filter(Boolean))];
|
|
let masterMap: Record<string, any> = {};
|
|
if (purchaseNos.length > 0) {
|
|
try {
|
|
const masterFilters: any[] = [{ columnName: "purchase_no", operator: "in", value: purchaseNos }, ...masterExtraFilters];
|
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
|
page: 1, size: purchaseNos.length + 10,
|
|
dataFilter: { enabled: true, filters: masterFilters },
|
|
autoFilter: true,
|
|
});
|
|
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
|
for (const m of masters) masterMap[m.purchase_no] = m;
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
|
let itemMap: Record<string, any> = {};
|
|
if (itemCodes.length > 0) {
|
|
try {
|
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: itemCodes.length + 10,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
|
autoFilter: true,
|
|
});
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
for (const item of items) itemMap[item.item_number] = item;
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
const resolveLabel = (key: string, code: string) => {
|
|
if (!code) return "";
|
|
const opts = categoryOptions[key];
|
|
if (!opts) return code;
|
|
return opts.find((o) => o.code === code)?.label || code;
|
|
};
|
|
|
|
const hasMasterFilters = masterExtraFilters.length > 0;
|
|
const data = rows
|
|
.filter((row: any) => {
|
|
const master = masterMap[row.purchase_no];
|
|
if (hasMasterFilters && !master) return false;
|
|
return true;
|
|
})
|
|
.map((row: any) => {
|
|
const item = itemMap[row.item_code];
|
|
const master = masterMap[row.purchase_no];
|
|
const rawUnit = row.unit || item?.unit || "";
|
|
return {
|
|
...row,
|
|
item_name: row.item_name || item?.item_name || "",
|
|
spec: row.spec || item?.size || "",
|
|
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
|
|
status: master?.status || "",
|
|
supplier_name: master?.supplier_name || "",
|
|
order_date: master?.order_date || "",
|
|
memo: row.memo || master?.memo || "",
|
|
};
|
|
});
|
|
|
|
setOrders(data);
|
|
setTotalCount(data.length);
|
|
} catch {
|
|
toast.error("발주 목록을 불러오는데 실패했어요.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters, categoryOptions]);
|
|
|
|
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
|
|
|
// purchase_no 기준 그룹핑
|
|
const orderGroups = useMemo(() => {
|
|
const map: Record<string, { master: any; details: any[] }> = {};
|
|
for (const row of orders) {
|
|
const key = row.purchase_no || row.id;
|
|
if (!map[key]) {
|
|
map[key] = {
|
|
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
|
|
details: [],
|
|
};
|
|
}
|
|
map[key].details.push(row);
|
|
}
|
|
return map;
|
|
}, [orders]);
|
|
|
|
const getCategoryLabel = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
const found = categoryOptions[col]?.find((o) => o.code === code);
|
|
return found?.label || code;
|
|
};
|
|
|
|
// 등록 모달 열기
|
|
const openRegisterModal = () => {
|
|
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
|
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
|
|
setMasterForm({
|
|
input_mode: defaultInputMode,
|
|
price_mode: defaultPriceMode,
|
|
manager: user?.userId || "",
|
|
order_date: new Date().toISOString().split("T")[0],
|
|
status: "작성중",
|
|
});
|
|
setDetailRows([]);
|
|
setIsEditMode(false);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
// 수정 모달 열기
|
|
const openEditModal = async (purchaseNo: string) => {
|
|
try {
|
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] },
|
|
autoFilter: true,
|
|
});
|
|
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
|
|
|
|
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] },
|
|
autoFilter: true,
|
|
});
|
|
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
|
|
|
setMasterForm(masterData || {});
|
|
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
|
|
setIsEditMode(true);
|
|
setIsModalOpen(true);
|
|
} catch {
|
|
toast.error("발주 정보를 불러오는데 실패했어요.");
|
|
}
|
|
};
|
|
|
|
// 삭제 (다중 선택)
|
|
const handleDelete = async () => {
|
|
if (checkedIds.length === 0) { toast.error("삭제할 발주를 선택해주세요."); return; }
|
|
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
|
const purchaseNos = [...new Set(selectedItems.map((o) => o.purchase_no))];
|
|
const ok = await confirm(`${checkedIds.length}건의 발주 데이터를 삭제하시겠어요?`, {
|
|
description: "삭제된 데이터는 복구할 수 없어요.",
|
|
variant: "destructive",
|
|
confirmText: "삭제",
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
|
data: checkedIds.map((id) => ({ id })),
|
|
});
|
|
for (const purchaseNo of purchaseNos) {
|
|
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] },
|
|
autoFilter: true,
|
|
});
|
|
const remainRows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
|
|
if (remainRows.length === 0) {
|
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] },
|
|
autoFilter: true,
|
|
});
|
|
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
|
if (masters.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
|
data: masters.map((m: any) => ({ id: m.id })),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
toast.success("삭제되었어요.");
|
|
setCheckedIds([]);
|
|
fetchOrders();
|
|
} catch {
|
|
toast.error("삭제에 실패했어요.");
|
|
}
|
|
};
|
|
|
|
// 저장 (마스터 + 디테일)
|
|
const handleSave = async () => {
|
|
if (detailRows.length === 0) {
|
|
toast.error("품목을 1개 이상 추가해주세요.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
|
|
|
|
if (isEditMode && id) {
|
|
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
|
|
originalData: { id },
|
|
updatedData: masterFields,
|
|
});
|
|
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: masterForm.purchase_no }] },
|
|
autoFilter: true,
|
|
});
|
|
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
|
|
if (existings.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
|
data: existings.map((d: any) => ({ id: d.id })),
|
|
});
|
|
}
|
|
for (const [idx, row] of detailRows.entries()) {
|
|
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
|
|
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
|
|
id: crypto.randomUUID(),
|
|
...detailFields,
|
|
purchase_no: masterForm.purchase_no,
|
|
seq_no: idx + 1,
|
|
});
|
|
}
|
|
} else {
|
|
const { purchase_no: _pn, ...fieldsWithoutPurchaseNo } = masterFields;
|
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { id: crypto.randomUUID(), ...fieldsWithoutPurchaseNo });
|
|
const createdData = masterRes.data?.data;
|
|
let purchaseNo = createdData?.purchase_no;
|
|
if (!purchaseNo) {
|
|
const queryRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
sort: { columnName: "created_date", order: "desc" },
|
|
autoFilter: true,
|
|
});
|
|
const records = queryRes.data?.data?.data || queryRes.data?.data?.rows || [];
|
|
purchaseNo = records[0]?.purchase_no;
|
|
}
|
|
if (!purchaseNo) {
|
|
toast.error("발주번호를 가져올 수 없어요. 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
for (const [idx, row] of detailRows.entries()) {
|
|
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
|
|
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
|
|
id: crypto.randomUUID(),
|
|
...detailFields,
|
|
purchase_no: purchaseNo,
|
|
seq_no: idx + 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
toast.success(isEditMode ? "수정되었어요." : "등록되었어요.");
|
|
setIsModalOpen(false);
|
|
fetchOrders();
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// 품목 검색
|
|
const searchItems = async (page?: number, size?: number) => {
|
|
const p = page ?? itemPage;
|
|
const s = size ?? itemPageSize;
|
|
setItemSearchLoading(true);
|
|
try {
|
|
const filters: any[] = [];
|
|
if (itemSearchKeyword) {
|
|
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
|
}
|
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const resData = res.data?.data;
|
|
let allRows = resData?.data || resData?.rows || [];
|
|
if (itemSearchDivision !== "all") {
|
|
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
|
allRows = allRows.filter((item: any) => {
|
|
const div = item.division || "";
|
|
return div.includes(itemSearchDivision) || (divLabel && div.includes(divLabel));
|
|
});
|
|
}
|
|
const total = allRows.length;
|
|
const start = (p - 1) * s;
|
|
setItemSearchResults(allRows.slice(start, start + s));
|
|
setItemTotal(total);
|
|
setItemTotalPages(Math.max(1, Math.ceil(total / s)));
|
|
} catch { /* skip */ } finally {
|
|
setItemSearchLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleItemPageChange = (newPage: number) => {
|
|
if (newPage < 1 || newPage > itemTotalPages) return;
|
|
setItemPage(newPage);
|
|
setItemPageInput(String(newPage));
|
|
searchItems(newPage);
|
|
};
|
|
|
|
const commitItemPageInput = () => {
|
|
const parsed = parseInt(itemPageInput, 10);
|
|
if (isNaN(parsed) || itemPageInput.trim() === "") {
|
|
setItemPageInput(String(itemPage));
|
|
return;
|
|
}
|
|
const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1));
|
|
if (clamped !== itemPage) handleItemPageChange(clamped);
|
|
setItemPageInput(String(clamped));
|
|
};
|
|
|
|
const triggerNewSearch = () => {
|
|
setItemPage(1);
|
|
setItemPageInput("1");
|
|
searchItems(1);
|
|
};
|
|
|
|
const addSelectedItemsToDetail = async () => {
|
|
const selected = Array.from(itemSelectedMap.values());
|
|
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
|
|
|
const supplierCode = masterForm.supplier_code;
|
|
const isStandard = masterForm.price_mode === "standard";
|
|
const isSupplier = masterForm.price_mode === "supplier";
|
|
let supplierPriceMap: Record<string, string> = {};
|
|
if (isSupplier && supplierCode) {
|
|
try {
|
|
const itemIds = selected.map((item) => item.item_number || item.id);
|
|
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: {
|
|
enabled: true,
|
|
filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
|
|
{ columnName: "item_id", operator: "in", value: itemIds },
|
|
],
|
|
},
|
|
autoFilter: true,
|
|
});
|
|
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
for (const p of prices) {
|
|
if (p.start_date && p.start_date > today) continue;
|
|
if (p.end_date && p.end_date < today) continue;
|
|
const price = p.calculated_price || p.base_price || p.unit_price || "";
|
|
if (price && Number(price) > 0) supplierPriceMap[p.item_id] = String(price);
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
const newRows = selected.map((item) => {
|
|
const itemCode = item.item_number || item.id;
|
|
let unitPrice = "";
|
|
if (isStandard) {
|
|
unitPrice = item.purchase_price || item.standard_price || "";
|
|
} else if (isSupplier && supplierCode) {
|
|
unitPrice = supplierPriceMap[itemCode] || "";
|
|
}
|
|
return {
|
|
_id: `new_${Date.now()}_${Math.random()}`,
|
|
item_code: itemCode,
|
|
item_name: item.item_name,
|
|
spec: item.size || "",
|
|
material: getCategoryLabel("item_material", item.material) || item.material || "",
|
|
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
|
|
order_qty: "",
|
|
received_qty: "0",
|
|
remain_qty: "0",
|
|
unit_price: unitPrice,
|
|
amount: "",
|
|
due_date: "",
|
|
memo: "",
|
|
};
|
|
});
|
|
|
|
setDetailRows((prev) => [...prev, ...newRows]);
|
|
toast.success(`${selected.length}개 품목이 추가되었어요.`);
|
|
setItemSelectedMap(new Map());
|
|
setItemSelectOpen(false);
|
|
};
|
|
|
|
// 단가 재계산: 단가방식/공급업체 변경 시 기존 품목 단가 갱신
|
|
const recalcPrices = useCallback(async (priceMode: string, supplierCode: string) => {
|
|
if (detailRows.length === 0) return;
|
|
const isStandard = priceMode === "standard";
|
|
const isSupplier = priceMode === "supplier";
|
|
|
|
if (isStandard) {
|
|
const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean);
|
|
if (itemCodes.length === 0) return;
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
|
autoFilter: true,
|
|
});
|
|
const items = res.data?.data?.data || res.data?.data?.rows || [];
|
|
const priceMap: Record<string, string> = {};
|
|
for (const item of items) {
|
|
const price = item.purchase_price || item.standard_price || "";
|
|
if (price) priceMap[item.item_number] = String(price);
|
|
}
|
|
setDetailRows((prev) => prev.map((row) => {
|
|
const up = priceMap[row.item_code] || "";
|
|
const qty = parseFloat(row.order_qty) || 0;
|
|
const price = parseFloat(up) || 0;
|
|
return { ...row, unit_price: up, amount: (qty * price).toString() };
|
|
}));
|
|
} catch { /* skip */ }
|
|
} else if (isSupplier && supplierCode) {
|
|
const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean);
|
|
if (itemCodes.length === 0) return;
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
|
|
{ columnName: "item_id", operator: "in", value: itemCodes },
|
|
]},
|
|
autoFilter: true,
|
|
});
|
|
const prices = res.data?.data?.data || res.data?.data?.rows || [];
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const priceMap: Record<string, string> = {};
|
|
for (const p of prices) {
|
|
if (p.start_date && p.start_date > today) continue;
|
|
if (p.end_date && p.end_date < today) continue;
|
|
const price = p.calculated_price || p.base_price || p.unit_price || "";
|
|
if (price && Number(price) > 0) priceMap[p.item_id] = String(price);
|
|
}
|
|
setDetailRows((prev) => prev.map((row) => {
|
|
const up = priceMap[row.item_code] || "";
|
|
const qty = parseFloat(row.order_qty) || 0;
|
|
const price = parseFloat(up) || 0;
|
|
return { ...row, unit_price: up, amount: (qty * price).toString() };
|
|
}));
|
|
} catch { /* skip */ }
|
|
}
|
|
}, [detailRows]);
|
|
|
|
const updateDetailRow = (idx: number, field: string, value: string) => {
|
|
setDetailRows((prev) => {
|
|
const next = [...prev];
|
|
next[idx] = { ...next[idx], [field]: value };
|
|
if (field === "order_qty" || field === "unit_price") {
|
|
const qty = parseFloat(field === "order_qty" ? value : next[idx].order_qty) || 0;
|
|
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
|
|
next[idx].amount = (qty * price).toString();
|
|
const received = parseFloat(next[idx].received_qty) || 0;
|
|
next[idx].remain_qty = (qty - received).toString();
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const removeDetailRow = (idx: number) => {
|
|
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const handleExcelDownload = async () => {
|
|
if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; }
|
|
const data = orders.map((o) => {
|
|
const row: Record<string, any> = {};
|
|
for (const col of EXCEL_COLUMNS) row[col.label] = o[col.key] || "";
|
|
return row;
|
|
});
|
|
await exportToExcel(data, "발주관리.xlsx", "발주목록");
|
|
toast.success("다운로드 완료");
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 p-3">
|
|
{/* 검색 필터 바 */}
|
|
<DynamicSearchFilter
|
|
tableName={DETAIL_TABLE}
|
|
filterId="c16-purchase-order"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={totalCount}
|
|
externalFilterConfig={ts.filterConfig}
|
|
/>
|
|
|
|
{/* 액션 바 */}
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-bold">발주 목록</h2>
|
|
<span className="inline-flex items-center rounded-full border border-primary/15 bg-primary/5 px-2.5 py-0.5 font-mono text-[11px] font-bold text-primary">
|
|
{totalCount}건
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openRegisterModal}>
|
|
<Plus className="w-4 h-4 mr-1" /> 발주 등록
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
|
|
const item = orders.find((o) => o.id === checkedIds[0]);
|
|
if (item) openEditModal(item.purchase_no);
|
|
}}>
|
|
<Pencil className="w-4 h-4 mr-1" /> 수정
|
|
</Button>
|
|
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
|
|
<Trash2 className="w-4 h-4 mr-1" /> 삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
|
</Button>
|
|
<div className="h-5 w-px bg-border mx-0.5" />
|
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
|
<FileSpreadsheet className="w-4 h-4 mr-1" /> 엑셀 업로드
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
|
<Download className="w-4 h-4 mr-1" /> 엑셀
|
|
</Button>
|
|
<div className="h-5 w-px bg-border mx-0.5" />
|
|
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
|
|
<Settings2 className="w-4 h-4 mr-1" /> 설정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 테이블 — 아코디언 그룹핑 */}
|
|
<div className="flex-1 overflow-auto border rounded-lg bg-card">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
|
) : Object.keys(orderGroups).length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<Package className="h-10 w-10 mb-2 opacity-30" />
|
|
<p className="text-sm">등록된 발주가 없어요</p>
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
|
|
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
|
|
|
|
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
|
|
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
|
|
const leadingMaster: typeof ts.visibleColumns = [];
|
|
const detailCols: typeof ts.visibleColumns = [];
|
|
const trailingMaster: typeof ts.visibleColumns = [];
|
|
let passedFirstDetail = false;
|
|
for (const col of ts.visibleColumns) {
|
|
if (MASTER_KEYS.has(col.key)) {
|
|
if (passedFirstDetail) trailingMaster.push(col);
|
|
else leadingMaster.push(col);
|
|
} else {
|
|
passedFirstDetail = true;
|
|
detailCols.push(col);
|
|
}
|
|
}
|
|
|
|
const renderDetailCell = (row: any, key: string) => {
|
|
const val = row[key];
|
|
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
|
|
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
|
|
return val || "-";
|
|
};
|
|
|
|
const renderMasterHead = (col: { key: string; label: string }) => (
|
|
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
|
|
{col.label}
|
|
</TableHead>
|
|
);
|
|
|
|
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
|
|
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
|
|
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
|
|
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
|
|
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
|
|
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
|
|
return <TableCell key={col.key} />;
|
|
};
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-8" />
|
|
<TableHead className="w-10" />
|
|
{leadingMaster.map(renderMasterHead)}
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center">품목수</TableHead>
|
|
{detailCols.map(col => (
|
|
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
|
|
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
|
|
</TableHead>
|
|
))}
|
|
{trailingMaster.map(renderMasterHead)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
|
|
const isExpanded = expandedOrders.has(purchaseNo);
|
|
const detailIds = group.details.map(d => d.id);
|
|
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
|
|
const someChecked = detailIds.some(id => checkedIds.includes(id));
|
|
const m = group.master;
|
|
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
|
|
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
|
|
return (
|
|
<React.Fragment key={purchaseNo}>
|
|
<TableRow
|
|
style={{ borderTop: "2px solid hsl(var(--border))" }}
|
|
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
|
|
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
|
|
onDoubleClick={() => openEditModal(purchaseNo)}
|
|
>
|
|
<TableCell className="text-center p-2">
|
|
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
|
|
</TableCell>
|
|
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
|
|
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
|
|
</TableCell>
|
|
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
|
|
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}건</Badge></TableCell>
|
|
{detailCols.map(col => (
|
|
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
|
|
{col.key === "order_qty" ? totalQty.toLocaleString()
|
|
: col.key === "amount" ? totalAmt.toLocaleString()
|
|
: ""}
|
|
</TableCell>
|
|
))}
|
|
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
|
|
</TableRow>
|
|
{isExpanded && group.details.map((row) => (
|
|
<TableRow key={row.id} className="bg-muted/30 text-xs">
|
|
<TableCell />
|
|
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
|
|
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
|
|
</TableCell>
|
|
{leadingMaster.map(col => <TableCell key={col.key} />)}
|
|
<TableCell />
|
|
{detailCols.map(col => (
|
|
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
|
|
{renderDetailCell(row, col.key)}
|
|
</TableCell>
|
|
))}
|
|
{trailingMaster.map(col => <TableCell key={col.key} />)}
|
|
</TableRow>
|
|
))}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
})()
|
|
)}
|
|
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
|
|
전체 {Object.keys(orderGroups).length}건 (발주 기준) / {orders.length}건 (품목 기준)
|
|
</div>
|
|
</div>
|
|
|
|
{/* 발주 등록/수정 모달 */}
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
<DialogContent
|
|
className="max-w-[95vw] w-[1200px]"
|
|
style={{ maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEditMode ? (isReadOnly ? "발주 상세 정보를 확인해요." : "발주 정보를 수정해요.") : "새로운 발주를 등록해요."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="space-y-6 py-2 pr-1">
|
|
{/* 기본 정보 */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
|
|
기본 정보
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-3.5">
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">발주번호</Label>
|
|
<Input value={masterForm.purchase_no || ""} placeholder={isEditMode ? "" : "자동생성"} className="h-9" disabled />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">발주일 <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
type="date"
|
|
value={masterForm.order_date || ""}
|
|
onChange={(e) => setMasterForm((p) => ({ ...p, order_date: e.target.value }))}
|
|
className="h-9"
|
|
disabled={isReadOnly}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">상태</Label>
|
|
{isReadOnly ? (
|
|
<div className="flex items-center h-9">
|
|
<span className={cn("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold", STATUS_BADGE_CLASS[masterForm.status] || "")}>
|
|
{masterForm.status}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<Select value={masterForm.status || "작성중"} onValueChange={(v) => setMasterForm((p) => ({ ...p, status: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<SelectItem key={s.code} value={s.code}>{s.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">입력방식</Label>
|
|
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))} disabled={isReadOnly}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(categoryOptions["input_mode"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">단가방식</Label>
|
|
<Select value={masterForm.price_mode || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, price_mode: v })); recalcPrices(v, masterForm.supplier_code || ""); }} disabled={isReadOnly}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(categoryOptions["price_mode"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">담당자</Label>
|
|
<Select value={masterForm.manager || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager: v }))} disabled={isReadOnly}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(categoryOptions["manager"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 공급업체 / 담당자 — 입력방식이 '공급업체 우선'일 때만 표시 */}
|
|
{masterForm.input_mode === "supplierFirst" && (
|
|
<div className="p-4 bg-muted/50 border border-dashed border-border rounded-lg space-y-3">
|
|
<div className="text-xs font-semibold text-primary flex items-center gap-1.5">
|
|
<ClipboardList className="w-3.5 h-3.5" /> 공급업체 정보
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3.5">
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">공급업체</Label>
|
|
<Select
|
|
value={masterForm.supplier_code || ""}
|
|
onValueChange={(v) => {
|
|
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
|
|
const name = supp?.label.replace(` (${v})`, "") || "";
|
|
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
|
|
recalcPrices(masterForm.price_mode || "", v);
|
|
}}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(categoryOptions["supplier_code"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 품목 내역 */}
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
|
|
<Package className="w-4 h-4" /> 품목 내역
|
|
<span className="inline-flex items-center rounded-full border border-primary/15 bg-primary/5 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
|
{detailRows.length}
|
|
</span>
|
|
</div>
|
|
{!isReadOnly && (
|
|
<Button size="sm" onClick={() => {
|
|
setItemSelectedMap(new Map());
|
|
setItemPage(1);
|
|
setItemPageInput("1");
|
|
setItemSearchKeyword("");
|
|
setItemSelectOpen(true);
|
|
searchItems(1);
|
|
}}>
|
|
<Plus className="w-4 h-4 mr-1" /> 품목 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{detailRows.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-2 py-8 border border-dashed border-border rounded-lg text-muted-foreground">
|
|
<Package className="w-8 h-8 opacity-40" />
|
|
<span className="text-sm">아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요.</span>
|
|
</div>
|
|
) : (
|
|
<div className="border rounded-lg overflow-x-auto">
|
|
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
|
|
<Table className="table-fixed">
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
|
|
{visibleModalColumns.map((col) => (
|
|
<SortableModalHead key={col.key} col={col} />
|
|
))}
|
|
</TableRow>
|
|
</SortableContext>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{detailRows.map((row, idx) => (
|
|
<TableRow key={row._id || idx}>
|
|
{!isReadOnly && (
|
|
<TableCell>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
|
|
<X className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
)}
|
|
{visibleModalColumns.map((col) => {
|
|
switch (col.key) {
|
|
case "item_code":
|
|
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
|
|
case "item_name":
|
|
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
|
|
case "supplier":
|
|
return (
|
|
<TableCell key={col.key}>
|
|
{isReadOnly ? (
|
|
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
|
|
) : (
|
|
<Select value={row.supplier_code || ""} onValueChange={(v) => {
|
|
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
|
|
const name = supp?.label.replace(` (${v})`, "") || "";
|
|
updateDetailRow(idx, "supplier_code", v);
|
|
updateDetailRow(idx, "supplier_name", name);
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(categoryOptions["supplier_code"] || []).map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</TableCell>
|
|
);
|
|
case "spec":
|
|
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
|
case "unit":
|
|
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
|
case "order_qty":
|
|
return (
|
|
<TableCell key={col.key}>
|
|
{isReadOnly ? (
|
|
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
|
|
) : (
|
|
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
|
)}
|
|
</TableCell>
|
|
);
|
|
case "received_qty":
|
|
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
|
case "remain_qty":
|
|
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
|
case "unit_price":
|
|
return (
|
|
<TableCell key={col.key}>
|
|
{isReadOnly ? (
|
|
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
|
) : (
|
|
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
|
|
)}
|
|
</TableCell>
|
|
);
|
|
case "amount":
|
|
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
|
case "due_date":
|
|
return (
|
|
<TableCell key={col.key}>
|
|
{isReadOnly ? (
|
|
<span className="text-xs">{row.due_date}</span>
|
|
) : (
|
|
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
|
|
)}
|
|
</TableCell>
|
|
);
|
|
case "memo":
|
|
return (
|
|
<TableCell key={col.key}>
|
|
{isReadOnly ? (
|
|
<span className="text-xs">{row.memo}</span>
|
|
) : (
|
|
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
|
|
)}
|
|
</TableCell>
|
|
);
|
|
default:
|
|
return <TableCell key={col.key} />;
|
|
}
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</DndContext>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 비고 */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
|
|
비고
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
<Input
|
|
value={masterForm.memo || ""}
|
|
onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
|
|
placeholder="특이사항이나 메모를 입력해주세요"
|
|
className="h-9"
|
|
disabled={isReadOnly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="border-t pt-3 mt-2">
|
|
{isReadOnly ? (
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>닫기</Button>
|
|
) : (
|
|
<>
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />} 저장
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
|
|
{/* 품목 선택 모달 (중첩) */}
|
|
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
|
<DialogContent className="max-w-3xl" style={{ maxHeight: "70vh", display: "flex", flexDirection: "column" }} onInteractOutside={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>발주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="품명/품목코드 검색"
|
|
value={itemSearchKeyword}
|
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
|
|
className="h-9 flex-1"
|
|
/>
|
|
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
|
|
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{(categoryOptions["item_division"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
|
|
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />조회</>}
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 overflow-auto border rounded-lg">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] text-center">
|
|
<input type="checkbox"
|
|
checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))}
|
|
onChange={(e) => {
|
|
setItemSelectedMap((prev) => {
|
|
const next = new Map(prev);
|
|
if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i));
|
|
else itemSearchResults.forEach((i) => next.delete(i.id));
|
|
return next;
|
|
});
|
|
}} />
|
|
</TableHead>
|
|
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
|
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
|
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{itemSearchResults.length === 0 ? (
|
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없어요</TableCell></TableRow>
|
|
) : itemSearchResults.map((item) => (
|
|
<TableRow key={item.id} className={cn("cursor-pointer", itemSelectedMap.has(item.id) && "bg-primary/5")}
|
|
onClick={() => setItemSelectedMap((prev) => {
|
|
const next = new Map(prev);
|
|
if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item);
|
|
return next;
|
|
})}>
|
|
<TableCell className="text-center">
|
|
<input type="checkbox" checked={itemSelectedMap.has(item.id)} readOnly />
|
|
</TableCell>
|
|
<TableCell className="text-[13px] max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
|
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
|
|
<TableCell className="text-[13px]">{item.size}</TableCell>
|
|
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
|
|
<TableCell className="text-[13px]">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<div className="flex items-center justify-between border-t pt-2">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">표시:</span>
|
|
<input type="number" min={1} max={200} value={itemPageSize}
|
|
onChange={(e) => {
|
|
const v = Math.min(200, Math.max(1, Number(e.target.value) || 20));
|
|
setItemPageSize(v);
|
|
setItemPage(1);
|
|
setItemPageInput("1");
|
|
searchItems(1, v);
|
|
}}
|
|
className="h-7 w-14 rounded-md border px-1 text-center text-xs" />
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="outline" size="sm" className="h-7 w-7 p-0" onClick={() => handleItemPageChange(1)} disabled={itemPage === 1 || itemSearchLoading}>
|
|
<ChevronsLeft className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-7 w-7 p-0" onClick={() => handleItemPageChange(itemPage - 1)} disabled={itemPage === 1 || itemSearchLoading}>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</Button>
|
|
<input type="text" inputMode="numeric" value={itemPageInput}
|
|
onChange={(e) => setItemPageInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }}
|
|
onBlur={commitItemPageInput}
|
|
onFocus={(e) => e.target.select()}
|
|
className="h-7 w-10 rounded-md border px-1 text-center text-xs" />
|
|
<span className="text-xs text-muted-foreground">/ {itemTotalPages || 1}</span>
|
|
<Button variant="outline" size="sm" className="h-7 w-7 p-0" onClick={() => handleItemPageChange(itemPage + 1)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-7 w-7 p-0" onClick={() => handleItemPageChange(itemTotalPages)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
|
|
<ChevronsRight className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">총 {itemTotal}건</span>
|
|
</div>
|
|
<DialogFooter>
|
|
<div className="flex items-center gap-2 w-full justify-between">
|
|
<span className="text-sm text-muted-foreground">{itemSelectedMap.size}개 선택됨</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemSelectOpen(false); }}>취소</Button>
|
|
<Button onClick={addSelectedItemsToDetail} disabled={itemSelectedMap.size === 0}>
|
|
<Plus className="w-4 h-4 mr-1" /> {itemSelectedMap.size}개 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 엑셀 업로드 */}
|
|
<ExcelUploadModal
|
|
open={excelUploadOpen}
|
|
onOpenChange={setExcelUploadOpen}
|
|
tableName={DETAIL_TABLE}
|
|
userId={user?.userId}
|
|
onSuccess={() => fetchOrders()}
|
|
/>
|
|
|
|
{/* 테이블 설정 모달 */}
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
|
|
{ConfirmDialogComponent}
|
|
</div>
|
|
);
|
|
}
|