"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 { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Truck, Package, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; 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 { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; // 천단위 구분자 표시용 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 FLAT_COLUMNS = [ { key: "order_no", label: "수주번호", source: "master" }, { key: "partner_id", label: "거래처", source: "master" }, { key: "order_date", label: "수주일", source: "master" }, { key: "part_code", label: "품번", source: "detail" }, { key: "part_name", label: "품명", source: "detail" }, { key: "spec", label: "규격", source: "detail" }, { key: "unit", label: "단위", source: "detail" }, { key: "qty", label: "수량", source: "detail" }, { key: "ship_qty", label: "출하수량", source: "detail" }, { key: "balance_qty", label: "잔량", source: "detail" }, { key: "unit_price", label: "단가", source: "detail" }, { key: "amount", label: "금액", source: "detail" }, { key: "due_date", label: "납기일", source: "detail" }, { key: "memo", label: "메모", source: "master" }, ]; const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail"); // 필터용 전체 키 const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label })); // 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15 const TOTAL_COLS = 15; // 헤더 필터 Popover function HeaderFilterPopover({ colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, }: { colKey: string; colLabel: string; uniqueValues: string[]; filterValues: Set; onToggle: (colKey: string, value: string) => void; onClear: (colKey: string) => void; }) { const [filterSearch, setFilterSearch] = useState(""); const hasFilter = filterValues.size > 0; const filteredValues = uniqueValues.filter( (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) ); return ( e.stopPropagation()}>
필터: {colLabel} {hasFilter && ( )}
setFilterSearch(e.target.value)} placeholder="검색..." className="h-7 text-xs pl-7" />
{filteredValues.slice(0, 100).map((val) => { const isSelected = filterValues.has(val); return (
onToggle(colKey, val)} >
{isSelected && }
{val || "(빈 값)"}
); })} {filteredValues.length > 100 && (
...외 {filteredValues.length - 100}개
)}
); } export default function SalesOrderPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(false); const [totalCount, setTotalCount] = useState(0); // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); // 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [saving, setSaving] = useState(false); const [masterForm, setMasterForm] = useState>({}); const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(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 [shippingPlanOpen, setShippingPlanOpen] = useState(false); // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); // 납품처 목록 const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); // 테이블 설정 const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); // 헤더 필터 & 정렬 const [headerFilters, setHeaderFilters] = useState>>({}); const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); // 카테고리 로드 useEffect(() => { const loadCategories = async () => { const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"]; const optMap: Record = {}; 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 LABEL_REPLACE: Record = { "공급업체 우선": "거래처 우선", "공급업체우선": "거래처 우선", }; const dedup = (items: { code: string; label: string }[]) => { const seen = new Set(); return items .map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label })) .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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } 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_id"] = 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 */ } // item_info 카테고리 for (const col of ["unit", "material", "division", "type"]) { 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); // division 기본값 const divs = optMap["item_division"] || []; const salesDiv = divs.find((o: any) => o.label === "영업관리") || divs.find((o: any) => o.label === "제품") || divs.find((o: any) => o.label === "판매품"); if (salesDiv) setItemSearchDivision(salesDiv.code); }; loadCategories(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { setLoading(true); try { const filters = searchFilters.map(f => ({ columnName: f.columnName, operator: f.operator, value: f.value, })); const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "order_no", order: "desc" }, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; // order_no → sales_order_mng 조인 const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; let masterMap: Record = {}; if (orderNos.length > 0) { try { const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: orderNos.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, autoFilter: true, }); const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; for (const m of masters) masterMap[m.order_no] = m; } catch { /* skip */ } } // part_code → item_info 조인 const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; if (partCodes.length > 0) { try { const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: partCodes.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] }, 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 data = rows.map((row: any) => { const item = itemMap[row.part_code]; const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""), unit: resolveLabel("item_unit", rawUnit) || rawUnit, memo: row.memo || master?.memo || "", _master: master || {}, }; }); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } }, [searchFilters, categoryOptions]); useEffect(() => { fetchOrders(); }, [fetchOrders]); // 카테고리 코드→라벨 변환 const resolveLabel = useCallback((key: string, code: string) => { if (!code) return ""; if (key === "partner_id" || key === "manager_id" || key === "price_mode") { return categoryOptions[key]?.find((o) => o.code === code)?.label || code; } return code; }, [categoryOptions]); // 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합) const flatRows = useMemo(() => { return orders.map((row) => { const master = row._master || {}; return { ...row, partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""), order_date: master.order_date || row.order_date || "", memo: row.memo || master.memo || "", }; }); }, [orders, resolveLabel]); // 컬럼별 고유값 (헤더 필터용) const columnUniqueValues = useMemo(() => { const result: Record = {}; for (const col of FLAT_COLUMNS) { const values = new Set(); flatRows.forEach((row) => { const val = row[col.key]; if (val !== null && val !== undefined && val !== "") values.add(String(val)); }); result[col.key] = Array.from(values).sort(); } return result; }, [flatRows]); // 필터 + 정렬 적용된 플랫 데이터 const filteredFlatRows = useMemo(() => { let rows = [...flatRows]; // 1차: 헤더 필터 적용 for (const [colKey, values] of Object.entries(headerFilters)) { if (values.size === 0) continue; rows = rows.filter((row) => { const cellVal = row[colKey] != null ? String(row[colKey]) : ""; return values.has(cellVal); }); } // 2차: 정렬 if (sortState) { const { key, direction } = sortState; rows.sort((a, b) => { const av = a[key] ?? ""; const bv = b[key] ?? ""; const na = Number(av); const nb = Number(bv); if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); }); } return rows; }, [flatRows, headerFilters, sortState]); // 헤더 필터 토글/초기화 const toggleHeaderFilter = (colKey: string, value: string) => { setHeaderFilters((prev) => { const next = { ...prev }; const set = new Set(next[colKey] || []); if (set.has(value)) set.delete(value); else set.add(value); if (set.size === 0) delete next[colKey]; else next[colKey] = set; return next; }); }; const clearHeaderFilter = (colKey: string) => { setHeaderFilters((prev) => { const next = { ...prev }; delete next[colKey]; return next; }); }; const handleSort = (key: string) => { setSortState((prev) => prev?.key === key ? prev.direction === "asc" ? { key, direction: "desc" } : null : { key, direction: "asc" } ); }; const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; const found = categoryOptions[col]?.find((o) => o.code === code); return found?.label || code; }; const loadDeliveryOptions = async (customerCode: string) => { if (!customerCode) { setDeliveryOptions([]); return; } try { const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { page: 1, size: 100, dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; setDeliveryOptions(rows.map((r: any) => ({ code: r.destination_code || r.id, label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, }))); } catch { setDeliveryOptions([]); } }; // 등록 모달 열기 const openRegisterModal = () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0], }); setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); setIsModalOpen(true); }; // 수정 모달 열기 const openEditModal = async (orderNo: string) => { try { const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); const 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: "order_no", operator: "equals", value: orderNo }] }, 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 (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } }; // 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제) const handleDelete = async () => { if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { // 1. 선택한 디테일 삭제 await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { data: checkedIds.map((id) => ({ id })), }); // 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제 const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; for (const orderNo of orderNos) { const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || []; if (remaining.length === 0) { // 디테일 0건 → 마스터 삭제 const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; if (masters.length > 0) { await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { data: masters.map((m: any) => ({ id: m.id })), }); } } } toast.success("삭제되었습니다."); setCheckedIds([]); fetchOrders(); } catch (err) { toast.error("삭제에 실패했습니다."); } }; // 저장 (마스터 + 디테일) const handleSave = async () => { if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; } 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: "order_no", operator: "equals", value: masterForm.order_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 })), }); } } else { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { 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`, { ...detailFields, id: crypto.randomUUID(), order_no: masterForm.order_no, }); } 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 isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; const partnerId = masterForm.partner_id; let customerItemIds: Set | null = null; if (isCustomerPrice && partnerId) { try { const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, autoFilter: true, }); const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); } catch { /* skip */ } } 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 (customerItemIds) { allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); } // 관리품목 필터 (코드/라벨 혼재 대응) 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 isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ"; const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; const partnerId = masterForm.partner_id; let customerPriceMap: Record = {}; if (isCustomerPrice && partnerId) { try { const itemIds = selected.map((item) => item.item_number || item.id); const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [ { columnName: "customer_id", operator: "equals", value: partnerId }, { columnName: "item_id", operator: "in", value: itemIds }, ], }, autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; const _n = new Date(); const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; if (start && start > today) continue; if (end && end < today) continue; const price = p.calculated_price || p.base_price || p.unit_price || ""; if (price && Number(price) > 0) { const existing = customerPriceMap[p.item_id]; if (!existing || Number(price) > 0) customerPriceMap[p.item_id] = String(price); } } } catch { /* skip */ } } const newRows = selected.map((item) => { const itemCode = item.item_number || item.id; let unitPrice = ""; if (isStandardPrice) { unitPrice = item.standard_price || item.selling_price || ""; } else if (isCustomerPrice && partnerId) { unitPrice = customerPriceMap[itemCode] || ""; } return { _id: `new_${Date.now()}_${Math.random()}`, part_code: itemCode, part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", packing_material: "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "1", pack_qty: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", }; }); setDetailRows((prev) => [...prev, ...newRows]); toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => { if (detailRows.length === 0) return; const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"]; const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"]; const isStandard = STANDARD_CODES.includes(priceMode); const isCustomer = CUSTOMER_CODES.includes(priceMode); if (isStandard) { // 품목 기준단가 조회 const itemCodes = detailRows.map((r) => r.part_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 = {}; for (const item of items) { const price = item.standard_price || item.selling_price || ""; if (price) priceMap[item.item_number] = String(price); } setDetailRows((prev) => prev.map((row) => { const up = priceMap[row.part_code] || ""; const qty = parseFloat(row.qty) || 0; const price = parseFloat(up) || 0; return { ...row, unit_price: up, amount: (qty * price).toString() }; })); } catch { /* skip */ } } else if (isCustomer && partnerId) { // 거래처별 단가 조회 const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean); if (itemCodes.length === 0) return; try { const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [ { columnName: "customer_id", operator: "equals", value: partnerId }, { columnName: "item_id", operator: "in", value: itemCodes }, ]}, autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; const _n = new Date(); const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; 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.part_code] || ""; const qty = parseFloat(row.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 === "qty" || field === "unit_price") { const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; }); }; const removeDetailRow = (idx: number) => { setDetailRows((prev) => prev.filter((_, i) => i !== idx)); }; // 조건부 레이어 판단 const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W"; const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO"; const handleExcelDownload = async () => { if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } const cols = ["order_no","part_code","part_name","spec","unit","qty","ship_qty","balance_qty","unit_price","amount","currency_code","due_date","memo"]; const labels: Record = { order_no:"수주번호",part_code:"품번",part_name:"품명",spec:"규격",unit:"단위", qty:"수량",ship_qty:"출하수량",balance_qty:"잔량",unit_price:"단가", amount:"금액",currency_code:"통화",due_date:"납기일",memo:"메모", }; const data = orders.map((o) => { const row: Record = {}; for (const col of cols) row[labels[col]] = o[col] || ""; return row; }); await exportToExcel(data, "수주관리.xlsx", "수주목록"); toast.success("다운로드 완료"); }; return (
{/* 브레드크럼 */} {/* 검색 필터 (DynamicSearchFilter) */} {/* 액션 바 */}

수주 목록

{totalCount}건
{/* 데이터 테이블 (플랫 리스트) */}
{ const allFilteredIds = filteredFlatRows.map((r) => r.id); const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); setCheckedIds(allChecked ? [] : allFilteredIds); }} > { const allFilteredIds = filteredFlatRows.map((r) => r.id); return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); })()} onCheckedChange={() => {}} /> {FLAT_COLUMNS.map((col) => { const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key); return (
handleSort(col.key)}> {col.label} {sortState?.key === col.key && ( sortState.direction === "asc" ? : )}
{(columnUniqueValues[col.key] || []).length > 0 && ( ()} onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} /> )}
); })}
{loading ? ( ) : filteredFlatRows.length === 0 ? (
등록된 수주가 없어요
) : ( filteredFlatRows.map((row) => { const isChecked = checkedIds.includes(row.id); return ( { setCheckedIds((prev) => prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); }} onDoubleClick={() => openEditModal(row.order_no)} > { e.stopPropagation(); setCheckedIds((prev) => prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] ); }} > {}} /> {row.order_no} {row.partner_id || ""} {row.order_date || ""} {row.part_code} {row.part_name} {row.spec} {row.unit} {row.qty ? Number(row.qty).toLocaleString() : ""} {row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""} {row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""} {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} {row.amount ? Number(row.amount).toLocaleString() : ""} {row.due_date || ""} {row.memo || ""} ); }) )}
{/* 수주 등록/수정 모달 */} {isEditMode ? "수주 수정" : "수주 등록"} {isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
{/* 기본 정보 섹션 */}
기본 정보
setMasterForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="수주번호" className="h-9" disabled={isEditMode} />
setMasterForm((p) => ({ ...p, order_date: e.target.value }))} className="h-9" />
{/* 거래처 우선 조건부 레이어 */} {isSupplierFirst && (
거래처 정보
{deliveryOptions.length > 0 ? ( ) : ( setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택해주세요"} className="h-9" disabled={!masterForm.partner_id} /> )}
setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))} placeholder="납품장소" className="h-9" />
)} {/* 해외판매 조건부 레이어 */} {isOverseas && (
해외판매 추가 정보
setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))} className="h-9" />
setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))} className="h-9" />
setMasterForm((p) => ({ ...p, hs_code: e.target.value }))} className="h-9 font-mono text-xs" />
)} {/* 품목 내역 리피터 */}
품목 내역 {detailRows.length}
{detailRows.length === 0 ? (
아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요.
) : (
No 품번 품명 규격 재질 포장재 단위 수량 포장수량 단가 금액 납기일 분할/삭제 {detailRows.map((row, idx) => ( {idx + 1} {row.part_code} {row.part_name} {row.spec} {row.material} updateDetailRow(idx, "packing_material", e.target.value)} placeholder="포장재" className="h-8 text-xs" /> updateDetailRow(idx, "qty", e.target.value)} className="h-8 text-xs text-right font-mono w-16" /> updateDetailRow(idx, "pack_qty", e.target.value)} className="h-8 text-xs text-right font-mono w-16" /> updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} readOnly={!allowPriceEdit} className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")} /> {row.amount ? Number(row.amount).toLocaleString() : "0"} updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
))}
)}
{/* 비고 */}
비고