From c5364e1d20c8d003b483728abf0210f2c41a87fa Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 20 May 2026 15:24:19 +0900 Subject: [PATCH] Enhance Outbound and Excel Service Functionality - Added `inventory_unit` to the item selection query in the outbound controller to improve data retrieval. - Updated the multi-table Excel service to exclude overlapping headers between parent and child levels, ensuring accurate data insertion. - Introduced new category combobox components for better user interaction in the supplied item page. - Enhanced the inbound-outbound page to correctly map user IDs, including super admin handling for user information retrieval. (TASK: ERP-XXX) --- .../src/controllers/outboundController.ts | 2 +- .../src/services/multiTableExcelService.ts | 26 +- .../COMPANY_7/sales/supplied-item/page.tsx | 2129 +++++++++++++++++ .../logistics/inbound-outbound/page.tsx | 19 +- .../COMPANY_8/logistics/outbound/page.tsx | 19 +- .../(main)/COMPANY_8/purchase/order/page.tsx | 1 + .../components/layout/AdminPageRenderer.tsx | 2 + frontend/lib/api/outbound.ts | 1 + 8 files changed, 2189 insertions(+), 10 deletions(-) create mode 100644 frontend/app/(main)/COMPANY_7/sales/supplied-item/page.tsx diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 8ebb0945..8a25e4cb 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -740,7 +740,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { const pool = getPool(); const result = await pool.query( `SELECT - id, item_number, item_name, size AS spec, material, unit, + id, item_number, item_name, size AS spec, material, unit, inventory_unit, COALESCE(width::text, '') AS width, COALESCE(height::text, '') AS height, COALESCE(thickness::text, '') AS thickness, diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts index 03e5db4c..b40d1884 100644 --- a/backend-node/src/services/multiTableExcelService.ts +++ b/backend-node/src/services/multiTableExcelService.ts @@ -156,6 +156,17 @@ class MultiTableExcelService { () => new Map() ); + // 자식 단계 빈 데이터 판정에 부모와 겹치는 헤더는 제외해야 한다. + // (예: customer_mng/customer_item_mapping 둘 다 '상태','비고' 헤더를 가지면 + // 부모용으로 입력한 값이 자식에도 잘못 들어가 자식 INSERT가 시도된다.) + const ownHeadersPerLevel: Set[] = activeLevels.map((lv, i) => { + const headers = new Set(lv.columns.map((c) => c.excelHeader)); + for (let j = 0; j < i; j++) { + for (const c of activeLevels[j].columns) headers.delete(c.excelHeader); + } + return headers; + }); + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { const row = rows[rowIdx]; @@ -178,8 +189,19 @@ class MultiTableExcelService { } } - const hasAnyData = Object.keys(levelData).length > 0; - if (!hasAnyData && lvlIdx > 0) { + // 자식 레벨은 '부모와 겹치지 않는 고유 헤더'에 값이 있을 때만 INSERT 진행 + const ownHeaders = ownHeadersPerLevel[lvlIdx]; + const hasOwnData = + lvlIdx === 0 + ? Object.keys(levelData).length > 0 + : level.columns.some( + (c) => + ownHeaders.has(c.excelHeader) && + levelData[c.dbColumn] !== undefined && + levelData[c.dbColumn] !== "" + ); + + if (!hasOwnData && lvlIdx > 0) { break; } diff --git a/frontend/app/(main)/COMPANY_7/sales/supplied-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/supplied-item/page.tsx new file mode 100644 index 00000000..c38c85d5 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/sales/supplied-item/page.tsx @@ -0,0 +1,2129 @@ +"use client"; + +/** + * 사급자재품목정보 — Type B 마스터-디테일 리디자인 + * + * 좌측: 사급자재 품목 목록 (item_info, division="사급관리" 필터) + * 우측: 선택한 품목의 거래처 정보 (customer_item_mapping → customer_mng 조인) + * + * 판매품목정보와 동일 구조, division 필터만 "사급관리"로 변경 + */ + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { ImageUpload } from "@/components/common/ImageUpload"; +import { SmartSelect } from "@/components/common/SmartSelect"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Users, Package, + Search, X, Settings2, GripVertical, ChevronRight, ChevronDown, Coins, + Check, ChevronsUpDown, +} from "lucide-react"; +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; + +// 검색 가능한 카테고리 콤보박스 +function CategoryCombobox({ options, value, onChange, placeholder }: { + options: { code: string; label: string }[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const selected = options.find((o) => o.code === value); + return ( + + + + + + + + + 검색 결과가 없어요 + + {options.map((opt) => ( + { onChange(opt.code); setOpen(false); }}> + + {opt.label} + + ))} + + + + + + ); +} + +// 다중 선택 카테고리 콤보박스 +function MultiCategoryCombobox({ options, value, onChange, placeholder }: { + options: { code: string; label: string }[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : []; + const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean); + + const toggle = (code: string) => { + const next = selectedCodes.includes(code) + ? selectedCodes.filter((c) => c !== code) + : [...selectedCodes, code]; + onChange(next.join(",")); + }; + + return ( + + + + + + + + + 검색 결과가 없어요 + + {options.map((opt) => ( + toggle(opt.code)}> + + {opt.label} + + ))} + + + + + + ); +} + +const ITEM_TABLE = "item_info"; +const MAPPING_TABLE = "customer_item_mapping"; +const CUSTOMER_TABLE = "customer_mng"; +const PRICE_TABLE = "customer_item_prices"; + +// 숫자 포맷 헬퍼 +const formatNum = (val: any): string => { + if (val === null || val === undefined || val === "") return ""; + const n = Number(val); + return isNaN(n) ? String(val) : n.toLocaleString(); +}; + +// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략) +const formatExpirySummary = (y: any, m: any, d: any): string => { + const toInt = (v: any) => { + if (v === null || v === undefined || v === "") return 0; + const n = Number(v); + return isNaN(n) ? 0 : Math.floor(n); + }; + const years = toInt(y), months = toInt(m), days = toInt(d); + const parts: string[] = []; + if (years) parts.push(`${years}년`); + if (months) parts.push(`${months}개월`); + if (days) parts.push(`${days}일`); + return parts.join(" "); +}; + +const ITEM_GRID_COLUMNS = [ + { key: "item_number", label: "품번" }, + { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, + { key: "inventory_unit", label: "단위" }, + { key: "standard_price", label: "기준단가" }, + { key: "selling_price", label: "판매가격" }, + { key: "currency_code", label: "통화" }, + { key: "expiry_summary", label: "유효기간" }, + { key: "status", label: "상태" }, +]; + +const FORM_FIELDS = [ + { key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" }, + { key: "item_name", label: "품명", type: "text", required: true }, + { key: "division", label: "관리품목", type: "multi-category" }, + { key: "type", label: "품목구분", type: "category" }, + { key: "size", label: "규격", type: "text" }, + { key: "inventory_unit", label: "단위", type: "category" }, + { key: "material", label: "재질", type: "category" }, + { key: "status", label: "상태", type: "category" }, + { key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" }, + { key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" }, + { key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" }, + { key: "inventory_unit", label: "재고단위", type: "category" }, + { key: "selling_price", label: "판매가격", type: "text" }, + { key: "standard_price", label: "기준단가", type: "text" }, + { key: "currency_code", label: "통화", type: "category" }, + { key: "user_type01", label: "대분류", type: "category" }, + { key: "user_type02", label: "중분류", type: "category" }, + { key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" }, + { key: "expiry", label: "유효기간", type: "expiry" }, + { key: "image", label: "품목 이미지", type: "image" }, + { key: "meno", label: "메모", type: "textarea" }, +]; + +const CATEGORY_COLUMNS_FOR_MODAL = [ + "division", "type", "unit", "material", "status", + "inventory_unit", "currency_code", "user_type01", "user_type02", +]; + +function SortableMappingRow({ id, children }: { id: string; children: React.ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( +
+
+ +
+ {children} +
+ ); +} + +export default function SuppliedItemPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + const ts = useTableSettings("c7-supplied-item", ITEM_TABLE, ITEM_GRID_COLUMNS); + const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + // 좌측: 품목 + const [items, setItems] = useState([]); + const [rawItems, setRawItems] = useState([]); + const [itemLoading, setItemLoading] = useState(false); + const [itemCount, setItemCount] = useState(0); + const [selectedItemId, setSelectedItemId] = useState(null); + + // 검색 필터 (DynamicSearchFilter에서 관리) + const [searchFilters, setSearchFilters] = useState([]); + + // 우측: 거래처 + const [customerItems, setCustomerItems] = useState([]); + const [priceGroups, setPriceGroups] = useState>({}); + const [customerLoading, setCustomerLoading] = useState(false); + const [customerCheckedIds, setCustomerCheckedIds] = useState([]); + const [expandedItems, setExpandedItems] = useState>(new Set()); + + // 카테고리 + const [categoryOptions, setCategoryOptions] = useState>({}); + const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); + + // 거래처 추가 모달 + const [custSelectOpen, setCustSelectOpen] = useState(false); + const [custSearchKeyword, setCustSearchKeyword] = useState(""); + const [custSearchResults, setCustSearchResults] = useState([]); + const [custSearchLoading, setCustSearchLoading] = useState(false); + const [custCheckedIds, setCustCheckedIds] = useState>(new Set()); + + // 품목 등록/수정 모달 + const [editItemOpen, setEditItemOpen] = useState(false); + const [editItemForm, setEditItemForm] = useState>({}); + const [isEditMode, setIsEditMode] = useState(false); + const [editId, setEditId] = useState(null); + const [saving, setSaving] = useState(false); + + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + + // 거래처 상세 입력 모달 (거래처 품번/품명 + 단가) + const [custDetailOpen, setCustDetailOpen] = useState(false); + const [selectedCustsForDetail, setSelectedCustsForDetail] = useState([]); + const [custMappings, setCustMappings] = useState>>({}); + const [custPrices, setCustPrices] = useState>>({}); + const [editCustData, setEditCustData] = useState(null); + const [collapsedPriceCards, setCollapsedPriceCards] = useState>(new Set()); + const [custSupplyTypes, setCustSupplyTypes] = useState>({}); + + + // 카테고리 로드 + useEffect(() => { + const load = async () => { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + // 거래처 거래유형 (customer_mng.division) + try { + const res = await apiClient.get(`/table-categories/customer_mng/division/values`); + if (res.data?.success) optMap["customer_division"] = flatten(res.data.data || []); + } catch { /* skip */ } + // 사급구분 (customer_item_mapping.supply_type) — 유상사급/무상사급 + try { + const res = await apiClient.get(`/table-categories/customer_item_mapping/supply_type/values`); + if (res.data?.success) optMap["supply_type"] = flatten(res.data.data || []); + } catch { /* skip */ } + setCategoryOptions(optMap); + + // 단가 카테고리 + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + }; + load(); + }, []); + + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + + // 좌측: 품목 조회 + const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } + setItemLoading(true); + try { + const filters: { columnName: string; operator: string; value: any }[] = []; + + // 사급관리 division 필터: 카테고리에서 "사급관리" 라벨의 코드를 찾아서 필터링 + const consignCode = categoryOptions["division"]?.find((o) => o.label === "사급관리")?.code; + if (consignCode) { + filters.push({ columnName: "division", operator: "contains", value: consignCode }); + } + + // DynamicSearchFilter에서 전달된 필터 추가 + for (const f of searchFilters) { + filters.push({ columnName: f.columnName, operator: f.operator, value: f.value }); + } + + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 0, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + setRawItems(raw); + const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + const data = raw.map((r: any) => { + const converted = { ...r }; + for (const col of CATS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days); + return converted; + }); + setItems(data); + setItemCount(res.data?.data?.total || raw.length); + } catch (err) { + console.error("품목 조회 실패:", err); + toast.error("품목 목록을 불러오는데 실패했습니다."); + } finally { + setItemLoading(false); + } + }, [searchFilters, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { fetchItems(); }, [fetchItems]); + + // 선택된 품목 + const selectedItem = items.find((i) => i.id === selectedItemId); + + // 우측: 거래처 목록 조회 + useEffect(() => { + if (!selectedItem?.item_number) { + setCustomerItems([]); + setPriceGroups({}); + setCustomerCheckedIds([]); + setExpandedItems(new Set()); + return; + } + setCustomerCheckedIds([]); + setExpandedItems(new Set()); + const itemKey = selectedItem.item_number; + const fetchCustomerItems = async () => { + setCustomerLoading(true); + try { + // 1. customer_item_mapping에서 해당 품목의 매핑 조회 + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, + }); + const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + + // 2. customer_id → customer_mng 조인 (거래처명) + const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))]; + let custMap: Record = {}; + if (custIds.length > 0) { + try { + const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { + page: 1, size: custIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] }, + autoFilter: true, + }); + for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) { + custMap[c.customer_code] = c; + } + } catch { /* skip */ } + } + + // 3. customer_item_prices 조회 (단가 정보) + let allPrices: any[] = []; + if (mappings.length > 0) { + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, + autoFilter: true, + }); + allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + } catch { /* skip */ } + } + + // 4. 거래처 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트 + const priceResolve = (col: string, code: string) => { + if (!code) return ""; + return priceCategoryOptions[col]?.find((o: any) => o.code === code)?.label || code; + }; + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + + const grouped: Record = {}; + const flatItems: any[] = []; + const seenCustIds = new Set(); + + for (const m of mappings) { + const custKey = m.customer_id || ""; + if (seenCustIds.has(custKey)) continue; // 거래처당 첫 매핑만 마스터 + seenCustIds.add(custKey); + + const custInfo = custMap[custKey] || {}; + const custPriceList = allPrices + .filter((p: any) => p.customer_id === custKey) + .sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || "")); + const todayPrice = custPriceList.find((p: any) => + (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) + ) || custPriceList[0] || {}; + + const masterRow = { + ...m, + customer_code: custKey, + customer_name: custInfo.customer_name || "", + supply_type: resolve("supply_type", m.supply_type || ""), + base_price_type: priceResolve("base_price_type", todayPrice.base_price_type || ""), + base_price: todayPrice.base_price || "", + discount_type: priceResolve("discount_type", todayPrice.discount_type || ""), + discount_value: todayPrice.discount_value || "", + calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", + currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), + }; + + // 단가 리스트 (라벨 변환) + const priceDetails = custPriceList.map((p: any) => ({ + ...p, + base_price_type_label: priceResolve("base_price_type", p.base_price_type || ""), + discount_type_label: priceResolve("discount_type", p.discount_type || ""), + currency_label: priceResolve("currency_code", p.currency_code || ""), + is_current: (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today), + })); + + grouped[custKey] = { master: masterRow, details: priceDetails }; + flatItems.push(masterRow); + } + setPriceGroups(grouped); + setCustomerItems(flatItems); + } catch (err) { + console.error("거래처 조회 실패:", err); + } finally { + setCustomerLoading(false); + } + }; + fetchCustomerItems(); + }, [selectedItem?.item_number, priceCategoryOptions, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps + + // 거래처 검색 + const searchCustomers = useCallback(async () => { + setCustSearchLoading(true); + try { + const filters: any[] = []; + if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword }); + const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const all = res.data?.data?.data || res.data?.data?.rows || []; + // 이미 등록된 거래처 제외 + const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code)); + setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code))); + } catch { /* skip */ } finally { setCustSearchLoading(false); } + }, [custSearchKeyword, customerItems]); + + // 실시간 검색 (2글자 이상) + useEffect(() => { + if (!custSelectOpen) return; + if (custSearchKeyword.length > 0 && custSearchKeyword.length < 2) return; + searchCustomers(); + }, [custSearchKeyword, custSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // 거래처 선택 → 상세 모달로 이동 + const goToCustDetail = () => { + const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id)); + if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; } + setSelectedCustsForDetail(selected); + const mappings: typeof custMappings = {}; + const prices: typeof custPrices = {}; + for (const cust of selected) { + const key = cust.customer_code || cust.id; + mappings[key] = []; + prices[key] = [{ + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "", + }]; + } + setCustMappings(mappings); + setCustPrices(prices); + // 사급구분 초기화 (선택한 거래처 키별로 빈 값) + const supplyTypes: Record = {}; + for (const cust of selected) { + const key = cust.customer_code || cust.id; + supplyTypes[key] = ""; + } + setCustSupplyTypes(supplyTypes); + setCustSelectOpen(false); + setCustDetailOpen(true); + }; + + const addMappingRow = (custKey: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }], + })); + }; + + const removeMappingRow = (custKey: string, rowId: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), + })); + }; + + const handleMappingDragEnd = (custKey: string, event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setCustMappings((prev) => { + const arr = [...(prev[custKey] || [])]; + const oldIdx = arr.findIndex((r) => r._id === active.id); + const newIdx = arr.findIndex((r) => r._id === over.id); + return { ...prev, [custKey]: arrayMove(arr, oldIdx, newIdx) }; + }); + }; + + const addPriceRow = (custKey: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: "", + }], + })); + }; + + const removePriceRow = (custKey: string, rowId: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => { + if (r._id !== rowId) return r; + const updated = { ...r, [field]: value }; + if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "rounding_type"].includes(field)) { + const bp = Number(updated.base_price) || 0; + const dv = Number(updated.discount_value) || 0; + const dt = updated.discount_type; + let calc = bp; + if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); + else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; + // 반올림 유형 + 단위 적용 + const rv = updated.rounding_unit_value; + const rt = updated.rounding_type; + const roundOpts = priceCategoryOptions["rounding_unit_value"] || []; + const roundLabel = roundOpts.find((o) => o.code === rv)?.label || ""; + const unitOpts = priceCategoryOptions["rounding_type"] || []; + const unitLabel = unitOpts.find((o) => o.code === rt)?.label || ""; + const unit = parseInt(unitLabel) || 1; + if (roundLabel === "반올림") calc = Math.round(calc / unit) * unit; + else if (roundLabel === "절삭") calc = Math.floor(calc / unit) * unit; + else if (roundLabel === "올림") calc = Math.ceil(calc / unit) * unit; + updated.calculated_price = String(Math.floor(calc)); + } + return updated; + }), + })); + }; + + const openEditCust = async (row: any) => { + const custKey = row.customer_code || row.customer_id; + + // customer_mng에서 거래처 정보 조회 + let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" }; + try { + const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] }, + autoFilter: true, + }); + const found = (res.data?.data?.data || res.data?.data?.rows || [])[0]; + if (found) custInfo = found; + } catch { /* skip */ } + + // 매핑 조회 + let mappingRows: any[] = []; + try { + const mapRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + ]}, autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + mappingRows = allMappings + .filter((m: any) => m.customer_item_code || m.customer_item_name) + .map((m: any) => ({ + _id: `m_existing_${m.id}`, + customer_item_code: m.customer_item_code || "", + customer_item_name: m.customer_item_name || "", + })); + // 사급구분: 첫 매핑 행의 supply_type을 거래처 단위 값으로 사용 + const firstSupplyType = allMappings.find((m: any) => m.supply_type)?.supply_type || ""; + setCustSupplyTypes((p) => ({ ...p, [custKey]: firstSupplyType })); + } catch { /* skip */ } + + // 단가 전체 조회 + let priceRows: any[] = []; + try { + const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + ]}, autoFilter: true, + }); + const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || []) + .sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || "")); + priceRows = allPriceData.map((p: any) => ({ + _id: `p_existing_${p.id}`, + start_date: p.start_date ? String(p.start_date).split("T")[0] : "", + end_date: p.end_date ? String(p.end_date).split("T")[0] : "", + currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: p.base_price ? String(p.base_price) : "", + discount_type: p.discount_type || "", + discount_value: p.discount_value ? String(p.discount_value) : "", + rounding_type: p.rounding_type || "", + rounding_unit_value: p.rounding_unit_value || "", + calculated_price: p.calculated_price ? String(p.calculated_price) : "", + })); + } catch { /* skip */ } + + if (priceRows.length === 0) { + priceRows.push({ + _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "", + rounding_type: "", rounding_unit_value: "", calculated_price: "", + }); + } + + setSelectedCustsForDetail([custInfo]); + setCustMappings({ [custKey]: mappingRows }); + setCustPrices({ [custKey]: priceRows }); + setEditCustData(row); + setCustDetailOpen(true); + }; + + const handleCustDetailSave = async () => { + if (!selectedItem) return; + const isEditingExisting = !!editCustData; + setSaving(true); + try { + for (const cust of selectedCustsForDetail) { + const custKey = cust.customer_code || cust.id; + const mappingRows = custMappings[custKey] || []; + + if (isEditingExisting && editCustData?.id) { + // 기존 매핑 조회 + let existingMaps: any[] = []; + try { + const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem.item_number }, + ]}, autoFilter: true, + sort: { columnName: "created_date", order: "asc" }, + }); + existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || []; + } catch { /* skip */ } + + // 매핑 upsert: 인덱스 기반 + const supplyTypeForCust = custSupplyTypes[custKey] || null; + const usedExistingIds = new Set(); + let firstMappingId: string | null = editCustData.id; + for (let mi = 0; mi < mappingRows.length; mi++) { + const existMap = existingMaps[mi]; + if (existMap) { + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: existMap.id }, + updatedData: { + customer_item_code: mappingRows[mi].customer_item_code || "", + customer_item_name: mappingRows[mi].customer_item_name || "", + supply_type: supplyTypeForCust, + }, + }); + usedExistingIds.add(existMap.id); + if (mi === 0) firstMappingId = existMap.id; + } else { + const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + customer_id: custKey, item_id: selectedItem.item_number, + customer_item_code: mappingRows[mi].customer_item_code || "", + customer_item_name: mappingRows[mi].customer_item_name || "", + supply_type: supplyTypeForCust, + }); + if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null; + } + } + // 초과분 delete + const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id)); + if (toDeleteMaps.length > 0) { + await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { + data: toDeleteMaps.map((m: any) => ({ id: m.id })), + }); + } + + // 기존 단가 조회 + let existingPriceRows: any[] = []; + try { + const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem.item_number }, + ]}, autoFilter: true, + }); + existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; + } catch { /* skip */ } + + // 단가 upsert: 사용자가 명시적으로 단가/시작일을 입력한 행만 저장 + // (currency_code/base_price_type은 디폴트 자동값이라 입력 의사 기준에서 제외) + const priceRows = (custPrices[custKey] || []).filter((p) => + p.base_price || p.start_date + ); + const usedPriceIds = new Set(); + for (let pi = 0; pi < priceRows.length; pi++) { + const price = priceRows[pi]; + const priceData = { + mapping_id: firstMappingId || editCustData.id, + customer_id: custKey, item_id: selectedItem.item_number, + start_date: price.start_date || null, end_date: price.end_date || null, + currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, + base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), + discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, + rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, + calculated_price: price.calculated_price ? Number(price.calculated_price) : null, + }; + const existPrice = existingPriceRows[pi]; + if (existPrice) { + await apiClient.put(`/table-management/tables/customer_item_prices/edit`, { + originalData: { id: existPrice.id }, + updatedData: priceData, + }); + usedPriceIds.add(existPrice.id); + } else { + await apiClient.post(`/table-management/tables/customer_item_prices/add`, { + id: crypto.randomUUID(), ...priceData, + }); + } + } + // 초과분 delete + const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id)); + if (toDeletePrices.length > 0) { + await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, { + data: toDeletePrices.map((p: any) => ({ id: p.id })), + }); + } + } else { + // 신규 등록 + const supplyTypeForCust = custSupplyTypes[custKey] || null; + const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + customer_id: custKey, item_id: selectedItem.item_number, + customer_item_code: mappingRows[0]?.customer_item_code || "", + customer_item_name: mappingRows[0]?.customer_item_name || "", + supply_type: supplyTypeForCust, + }); + const mappingId = mappingRes.data?.data?.id || null; + + for (let mi = 1; mi < mappingRows.length; mi++) { + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + customer_id: custKey, item_id: selectedItem.item_number, + customer_item_code: mappingRows[mi].customer_item_code || "", + customer_item_name: mappingRows[mi].customer_item_name || "", + supply_type: supplyTypeForCust, + }); + } + + // 단가 INSERT: 사용자가 명시적으로 단가/시작일을 입력한 행만 저장 + // (currency_code/base_price_type은 디폴트 자동값이라 입력 의사 기준에서 제외) + const priceRows = (custPrices[custKey] || []).filter((p) => + p.base_price || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/customer_item_prices/add`, { + id: crypto.randomUUID(), + mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number, + start_date: price.start_date || null, end_date: price.end_date || null, + currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, + base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), + discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, + rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, + calculated_price: price.calculated_price ? Number(price.calculated_price) : null, + }); + } + } + } + toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`); + setCustDetailOpen(false); + setEditCustData(null); + setCustCheckedIds(new Set()); + // 우측 새로고침 + const sid = selectedItemId; + setSelectedItemId(null); + setTimeout(() => setSelectedItemId(sid), 50); + } catch (err: any) { + console.error("거래처 상세 저장 실패:", err.response?.data); + const detail = err.response?.data?.error?.details; + const msg = err.response?.data?.message || "저장에 실패했습니다."; + toast.error(detail ? `${msg} (${typeof detail === "string" ? detail : JSON.stringify(detail)})` : msg); + } finally { + setSaving(false); + } + }; + + // 프리뷰 코드에서 각 파트별 표시값을 추출 + const parsePreviewIntoParts = (previewCode: string, rule: any) => { + if (!previewCode || !rule?.parts) return []; + const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const globalSep = rule.separator || ""; + + const partMeta = sorted.map((part: any, idx: number) => { + const sep = idx < sorted.length - 1 + ? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep) + : ""; + const config = part.autoConfig || {}; + if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType }; + switch (part.partType) { + case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" }; + case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" }; + case "date": { + const now = new Date(); + const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0"); + const fmt = config.dateFormat || "YYYYMMDD"; + const map: Record = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d }; + return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" }; + } + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + let remaining = previewCode; + const results: { value: string; isManual: boolean; separator: string }[] = []; + + for (let i = 0; i < partMeta.length; i++) { + const meta = partMeta[i]; + const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null; + + if (meta.isManual) { + const markerIdx = remaining.indexOf("____"); + if (markerIdx >= 0) { + remaining = remaining.substring(markerIdx + 4); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + } + results.push({ value: "", isManual: true, separator: meta.sep }); + continue; + } + + if (meta.known) { + const valIdx = remaining.indexOf(meta.value); + if (valIdx >= 0) { + remaining = remaining.substring(valIdx + meta.value.length); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + } + results.push({ value: meta.value, isManual: false, separator: meta.sep }); + } else { + let endIdx = remaining.length; + if (meta.sep) { + if (nextMeta) { + if (nextMeta.known && nextMeta.value) { + const patIdx = remaining.indexOf(meta.sep + nextMeta.value); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + const patIdx = remaining.indexOf(meta.sep + "____"); + if (patIdx >= 0) endIdx = patIdx; + } else { + const sepIdx = remaining.indexOf(meta.sep); + if (sepIdx >= 0) endIdx = sepIdx; + } + } + } else if (nextMeta) { + if (nextMeta.known && nextMeta.value) { + const valIdx = remaining.indexOf(nextMeta.value); + if (valIdx >= 0) endIdx = valIdx; + } else if (nextMeta.isManual) { + const markerIdx = remaining.indexOf("____"); + if (markerIdx >= 0) endIdx = markerIdx; + } + } + const extracted = remaining.substring(0, endIdx); + remaining = remaining.substring(endIdx); + if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length); + results.push({ value: extracted, isManual: false, separator: meta.sep }); + } + } + return results; + }; + + // 파트 값으로부터 전체 코드 조합 + const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => { + return parts.map((p, idx) => { + const val = p.isManual ? manualVal : p.value; + const sep = idx < parts.length - 1 ? p.separator : ""; + return val + sep; + }).join(""); + }; + + // 채번 미리보기 로드 + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { + try { + setIsNumberingLoading(true); + + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } + } + + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; + } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 품목 등록 모달 열기 — 사급자재품목정보에서 등록 시 division 기본값을 "사급관리"로 세팅 + const openRegisterModal = async () => { + const consignCode = categoryOptions["division"]?.find((o) => o.label === "사급관리")?.code || ""; + const initialForm = consignCode ? { division: consignCode } : {}; + setEditItemForm(initialForm); + setManualInputValue(""); + setNumberingParts([]); + setIsEditMode(false); + setEditId(null); + setEditItemOpen(true); + const result = await loadNumberingPreview(initialForm); + if (result.code) { + const hasManual = result.parts.some(p => p.isManual); + const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code; + setEditItemForm(prev => ({ ...prev, item_number: displayCode })); + } + }; + + // 품목 수정 모달 열기 + const openEditItem = (item?: any) => { + const target = item || selectedItem; + if (!target) return; + const raw = rawItems.find((r) => r.id === target.id) || target; + setEditItemForm({ ...raw }); + setManualInputValue(""); + setNumberingParts([]); + setIsEditMode(true); + setEditId(target.id); + setEditItemOpen(true); + }; + + // 카테고리 변경 시 채번 preview 재호출 + useEffect(() => { + if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return; + + const hasCategoryPart = numberingRule?.parts?.some( + (p: any) => p.partType === "category" && p.generationMethod === "auto" + ); + if (!hasCategoryPart) return; + + const timer = setTimeout(async () => { + const result = await loadNumberingPreview(editItemForm, manualInputValue); + if (result.parts.length > 0) { + setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } + }, 300); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...CATEGORY_COLUMNS_FOR_MODAL.map(col => editItemForm[col])]); + + // 수동 입력값 변경 시 preview 갱신 + useEffect(() => { + if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return; + if (!numberingParts.some(p => p.isManual)) return; + + const timer = setTimeout(async () => { + const result = await loadNumberingPreview(editItemForm, manualInputValue); + if (result.parts.length > 0) { + setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + + // 품목 저장 (등록 + 수정 통합) + const handleEditSave = async () => { + if (!editItemForm.item_name) { + toast.error("품명은 필수 입력이에요."); + return; + } + setSaving(true); + try { + if (isEditMode && editId) { + const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = editItemForm; + await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { + originalData: { id: editId }, + updatedData: updateFields, + }); + toast.success("수정되었어요."); + } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = editItemForm.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual && manualInputValue + ? manualInputValue + : undefined; + + const allocRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/allocate`, + { formData: editItemForm, userInputCode } + ); + + if (allocRes.data?.success && allocRes.data?.data?.generatedCode) { + finalItemNumber = allocRes.data.data.generatedCode; + } + } catch (err) { + console.error("채번 할당 실패:", err); + toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요."); + setSaving(false); + return; + } + } + + const { id, created_date, updated_date, expiry_summary, ...insertFields } = editItemForm; + await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); + toast.success("등록되었어요."); + } + setEditItemOpen(false); + fetchItems(); + } catch (err: any) { + console.error("저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했어요."); + } finally { + setSaving(false); + } + }; + + // 우측: 거래처 매핑 해제 (소프트 삭제 — item_id를 null 처리) + const handleCustomerMappingDelete = async () => { + if (customerCheckedIds.length === 0) return; + const ok = await confirm(`선택한 ${customerCheckedIds.length}개 거래처의 연결을 해제하시겠습니까?`, { + description: "해당 거래처의 품목 연결이 해제됩니다. (데이터는 유지)", + variant: "destructive", confirmText: "해제", + }); + if (!ok) return; + try { + const customerCodes = customerCheckedIds.map((mid) => { + const group = Object.values(priceGroups).find((g) => g.master.id === mid); + return group?.master.customer_id || group?.master.customer_code || ""; + }).filter(Boolean); + + for (const custCode of customerCodes) { + // 해당 거래처의 모든 매핑 조회 → item_id null 처리 + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + { columnName: "customer_id", operator: "equals", value: custCode }, + ]}, autoFilter: true, + }); + const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + for (const m of allMappings) { + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: m.id }, + updatedData: { item_id: null }, + }); + } + + // 해당 거래처의 모든 단가 조회 → item_id null 처리 + try { + const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, + { columnName: "customer_id", operator: "equals", value: custCode }, + ]}, autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + await apiClient.put(`/table-management/tables/customer_item_prices/edit`, { + originalData: { id: p.id }, + updatedData: { item_id: null }, + }); + } + } catch { /* skip */ } + } + + toast.success(`${customerCheckedIds.length}개 거래처의 연결이 해제되었습니다.`); + setCustomerCheckedIds([]); + const sid = selectedItemId; + setSelectedItemId(null); + setTimeout(() => setSelectedItemId(sid), 50); + } catch { + toast.error("연결 해제에 실패했습니다."); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (items.length === 0) return; + const data = items.map((i) => ({ + 품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.inventory_unit, + 기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status, + })); + await exportToExcel(data, "사급자재품목정보.xlsx", "사급자재품목"); + toast.success("다운로드 완료"); + }; + + // EDataTable 컬럼 정의 (사급자재품목) + const itemColumns: EDataTableColumn[] = [ + { key: "item_number", label: "품번", width: "w-[110px]" }, + { key: "item_name", label: "품명", minWidth: "min-w-[130px]" }, + { key: "size", label: "규격", width: "w-[80px]" }, + { key: "inventory_unit", label: "단위", width: "w-[60px]" }, + { key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, + { key: "expiry_summary", label: "유효기간", width: "w-[110px]" }, + { key: "status", label: "상태", width: "w-[60px]" }, + ]; + + return ( +
+ {/* 검색 필터 (DynamicSearchFilter) */} + + + {/* 액션 버튼 영역 */} +
+
+ + +
+
+ + {/* 마스터-디테일 분할 패널 */} +
+ + + {/* 좌측: 사급자재 품목 목록 */} + +
+ {/* 패널 헤더 */} +
+
+ 사급자재 품목 목록 + + {itemCount}건 + +
+
+ + + +
+
+ + {/* 거래처 테이블 */} + row.id} + loading={itemLoading} + emptyMessage="등록된 사급자재 품목이 없어요" + selectedId={selectedItemId} + onSelect={(id) => setSelectedItemId(id)} + onRowDoubleClick={(row) => openEditItem(row)} + showRowNumber + showPagination + defaultPageSize={20} + draggableColumns={false} + columnOrderKey="c7-supplied-item" + /> +
+
+ + + + {/* 우측: 디테일 패널 */} + +
+ {!selectedItemId ? ( + /* 빈 상태 */ +
+
+ +
품목을 선택해주세요
+
좌측에서 품목을 선택하면 상세 정보가 표시돼요
+
+
+ ) : ( + <> + {/* 거래처별 단가 헤더 */} +
+
+ + 거래처별 단가 + {Object.keys(priceGroups).length > 0 && ( + {Object.keys(priceGroups).length} + )} +
+
+ + +
+
+ + {/* 거래처 테이블 */} +
+ + + + + 0 && customerCheckedIds.length === customerItems.length} + onChange={(e) => setCustomerCheckedIds(e.target.checked ? customerItems.map((c) => c.id) : [])} + /> + + 거래처코드 + 거래처명 + 사급구분 + 거래처품번 + 거래처품명 + 기준유형 + 기준가 + 할인유형 + 할인값 + 단가 + 통화 + + + + {customerLoading ? ( + + + + + + ) : Object.keys(priceGroups).length === 0 ? ( + + + 등록된 거래처가 없어요 + + + ) : Object.entries(priceGroups).map(([custKey, group]) => { + const isExpanded = expandedItems.has(custKey); + const m = group.master; + const isChecked = customerCheckedIds.includes(m.id); + return ( + + {/* 마스터 행 */} + { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(custKey)) next.delete(custKey); else next.add(custKey); + return next; + }); + }} + onDoubleClick={() => openEditCust(m)} + > + { + e.stopPropagation(); + setCustomerCheckedIds((prev) => + prev.includes(m.id) ? prev.filter((id) => id !== m.id) : [...prev, m.id] + ); + }} + > + + + +
+ {isExpanded + ? + : + } + {m.customer_code} +
+
+ {m.customer_name} + + {m.supply_type ? ( + {m.supply_type} + ) : ( + - + )} + + {m.customer_item_code} + {m.customer_item_name} + {m.base_price_type} + + {m.base_price ? Number(m.base_price).toLocaleString() : ""} + + {m.discount_type} + {m.discount_value ? Number(m.discount_value).toLocaleString() : ""} + + {m.calculated_price ? Number(m.calculated_price).toLocaleString() : ""} + + {m.currency_code} +
+ + {/* 현재 단가 카드 (펼쳤을 때) */} + {isExpanded && (() => { + const cp = group.details.find((p) => p.is_current) || group.details[0]; + if (!cp) return ( + + 등록된 단가가 없어요 + + ); + return ( + + +
+ {/* 카드 헤더 */} +
+
+ + 적용 단가 + 현재 +
+ {group.details.length > 1 && ( + 전체 {group.details.length}건 중 + )} +
+ {/* 카드 내용 */} +
+
+ 기간 + + {cp.start_date ? String(cp.start_date).split("T")[0] : "—"} ~ {cp.end_date ? String(cp.end_date).split("T")[0] : "—"} + +
+
+ 기준유형 + {cp.base_price_type_label || "-"} +
+
+ 기준가 + {cp.base_price ? Number(cp.base_price).toLocaleString() : "-"} +
+
+ 할인유형 + {cp.discount_type_label && cp.discount_type_label !== "할인없음" ? cp.discount_type_label : "-"} +
+
+ 할인값 + {cp.discount_value ? Number(cp.discount_value).toLocaleString() : "-"} +
+
+ 단수처리 + + {cp.rounding_unit_value + ? (priceCategoryOptions["rounding_unit_value"]?.find((o) => o.code === cp.rounding_unit_value)?.label || cp.rounding_unit_value) + : "-"} + +
+ +
+ 계산단가 + + {(cp.calculated_price || cp.unit_price) ? Number(cp.calculated_price || cp.unit_price).toLocaleString() : "-"} + {cp.currency_label} + +
+
+
+
+
+ ); + })()} +
+ ); + })} +
+
+
+ + )} +
+
+
+
+ + {/* ── 품목 등록/수정 모달 ── */} + + + + {isEditMode ? "품목 수정" : "품목 등록"} + + {isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."} + + + +
+
+ {FORM_FIELDS.map((field) => ( +
+ + {field.type === "numbering" ? ( + isEditMode ? ( + + ) : isNumberingLoading && numberingParts.length === 0 ? ( +
+ + 생성 중... +
+ ) : numberingParts.some(p => p.isManual) ? ( +
+ {numberingParts.map((part, idx) => { + const isFirst = idx === 0; + const isLast = idx === numberingParts.length - 1; + if (part.isManual) { + return ( + + { + const val = e.target.value; + setManualInputValue(val); + setEditItemForm(prev => ({ + ...prev, + item_number: buildCodeFromParts(numberingParts, val), + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {part.separator && !isLast && ( + {part.separator} + )} + + ); + } + return ( + + + {part.value} + + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
+ ) : ( + + ) + ) : field.type === "image" ? ( + setEditItemForm((prev) => ({ ...prev, [field.key]: v }))} + tableName={ITEM_TABLE} + recordId={editItemForm.id || ""} + columnName={field.key} + height="h-32" + /> + ) : field.type === "multi-category" ? ( + setEditItemForm((prev) => ({ ...prev, [field.key]: v }))} + placeholder={`${field.label} 선택`} + /> + ) : field.type === "category" ? ( + setEditItemForm((prev) => ({ ...prev, [field.key]: v }))} + placeholder={`${field.label} 선택`} + /> + ) : field.type === "textarea" ? ( +