"use client"; /** * 구매품목관리 — Type B 마스터-디테일 리디자인 * * 좌측: 구매품목 목록 (item_info, 구매 관련 필터) * 우측: 선택한 품목의 공급업체 정보 (supplier_item_mapping → supplier_mng 조인) * * 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블) */ 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 { 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 { Badge } from "@/components/ui/badge"; 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 { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package, ChevronRight, ChevronDown, Coins, GripVertical, 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"; const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "supplier_item_mapping"; const SUPPLIER_TABLE = "supplier_mng"; // 검색 가능한 카테고리 콤보박스 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 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: "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: "image", label: "품목 이미지", type: "image" }, { key: "meno", label: "메모", type: "textarea" }, ] as const; const CATEGORY_COLUMNS = [ "division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02", ]; // 숫자 포맷 헬퍼 const formatNum = (val: any): string => { if (val === null || val === undefined || val === "") return ""; const n = Number(val); return isNaN(n) ? String(val) : n.toLocaleString(); }; const ITEM_GRID_COLUMNS = [ { key: "item_number", label: "품번" }, { key: "item_name", label: "품명" }, { key: "size", label: "규격" }, { key: "unit", label: "단위" }, { key: "standard_price", label: "기준단가/구매단가" }, { key: "currency_code", label: "통화" }, { key: "status", label: "상태" }, ]; 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 PurchaseItemPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); const ts = useTableSettings("c16-purchase-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); // 품목 등록/수정 모달 (item-info 스타일) const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [editId, setEditId] = useState(null); const [formData, setFormData] = useState>({}); // 채번 관련 상태 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); // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); // 우측: 공급업체 const [supplierItems, setSupplierItems] = useState([]); const [supplierGroups, setSupplierGroups] = useState>({}); const [supplierLoading, setSupplierLoading] = useState(false); const [supplierCheckedIds, setSupplierCheckedIds] = useState([]); const [expandedItems, setExpandedItems] = useState>(new Set()); const [collapsedPriceCards, setCollapsedPriceCards] = useState>(new Set()); // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); // 공급업체 추가 모달 const [suppSelectOpen, setSuppSelectOpen] = useState(false); const [suppSearchKeyword, setSuppSearchKeyword] = useState(""); const [suppSearchResults, setSuppSearchResults] = useState([]); const [suppSearchLoading, setSuppSearchLoading] = useState(false); const [suppCheckedIds, setSuppCheckedIds] = useState>(new Set()); const [saving, setSaving] = useState(false); // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [excelChainConfig, setExcelChainConfig] = useState(null); const [excelDetecting, setExcelDetecting] = useState(false); // 공급업체 상세 입력 모달 (공급업체 품번/품명 + 단가) const [suppDetailOpen, setSuppDetailOpen] = useState(false); const [selectedSuppsForDetail, setSelectedSuppsForDetail] = useState([]); const [suppMappings, setSuppMappings] = useState>>({}); const [suppPrices, setSuppPrices] = useState>>({}); const [editSuppData, setEditSuppData] = useState(null); // 카테고리 로드 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; }; await Promise.all( CATEGORY_COLUMNS.map(async (col) => { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success && res.data.data?.length > 0) optMap[col] = 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/supplier_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 () => { setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; // 구매관리 division 필터: 카테고리에서 "구매관리" 라벨의 코드를 찾아서 필터링 const purchaseCode = categoryOptions["division"]?.find((o) => o.label === "구매관리")?.code; if (purchaseCode) { filters.push({ columnName: "division", operator: "contains", value: purchaseCode }); } // 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: 5000, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; setRawItems(raw); const data = raw.map((r: any) => { const converted = { ...r }; for (const col of CATEGORY_COLUMNS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } 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 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 }[] }; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); setManualInputValue(""); setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); const result = await loadNumberingPreview({}); if (result.code) { const hasManual = result.parts.some(p => p.isManual); const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code; setFormData(prev => ({ ...prev, item_number: displayCode })); } }; // 수정 모달 열기 const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); }; // 카테고리 변경 시 채번 preview 재호출 useEffect(() => { if (isEditMode || !isModalOpen || !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(formData, manualInputValue); if (result.parts.length > 0) { setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [...CATEGORY_COLUMNS.map(col => formData[col])]); // 수동 입력값 변경 시 preview 갱신 useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { const result = await loadNumberingPreview(formData, manualInputValue); if (result.parts.length > 0) { setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 500); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [manualInputValue]); // 저장 (등록 또는 수정) const handleSave = async () => { if (!formData.item_name) { toast.error("품명은 필수 입력이에요."); return; } setSaving(true); try { if (isEditMode && editId) { const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { originalData: { id: editId }, updatedData: updateFields, }); toast.success("수정되었어요."); } else { // 신규 등록: allocateCode 호출하여 실제 순번 확보 let finalItemNumber = formData.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, 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, ...insertFields } = formData; await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields, item_number: finalItemNumber, }); toast.success("등록되었어요."); } setIsModalOpen(false); fetchItems(); } catch (err: any) { console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했어요."); } finally { setSaving(false); } }; // 선택된 품목 const selectedItem = items.find((i) => i.id === selectedItemId); // 우측: 공급업체 목록 조회 useEffect(() => { if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierGroups({}); setSupplierCheckedIds([]); return; } setSupplierCheckedIds([]); const itemKey = selectedItem.item_number; const fetchSupplierItems = async () => { setSupplierLoading(true); try { // 1. supplier_item_mapping에서 해당 품목의 매핑 조회 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { page: 1, size: 500, 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. supplier_id → supplier_mng 조인 (공급업체명) const custIds = [...new Set(mappings.map((m: any) => m.supplier_id).filter(Boolean))]; let custMap: Record = {}; if (custIds.length > 0) { try { const custRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: custIds.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "in", value: custIds }] }, autoFilter: true, }); for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) { custMap[c.supplier_code] = c; } } catch { /* skip */ } } // 3. supplier_item_prices 조회 (단가 정보) let allPrices: any[] = []; if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { page: 1, size: 500, 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 today = new Date().toISOString().split("T")[0]; const seenCustIds = new Set(); const grouped: Record = {}; const flatItems: any[] = []; for (const m of mappings) { const custKey = m.supplier_id || ""; if (seenCustIds.has(custKey)) continue; // 공급업체당 첫 매핑만 마스터 seenCustIds.add(custKey); const custInfo = custMap[custKey] || {}; const custPriceList = allPrices .filter((p: any) => p.supplier_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, supplier_code: custKey, supplier_name: custInfo.supplier_name || "", supplier_item_code: m.supplier_item_code || "", supplier_item_name: m.supplier_item_name || "", 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); } setSupplierGroups(grouped); setSupplierItems(flatItems); } catch (err) { console.error("공급업체 조회 실패:", err); } finally { setSupplierLoading(false); } }; fetchSupplierItems(); }, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 검색 const searchSuppliers = useCallback(async () => { setSuppSearchLoading(true); try { const filters: any[] = []; if (suppSearchKeyword) filters.push({ columnName: "supplier_name", operator: "contains", value: suppSearchKeyword }); const res = await apiClient.post(`/table-management/tables/${SUPPLIER_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(supplierItems.map((c: any) => c.supplier_id || c.supplier_code)); setSuppSearchResults(all.filter((c: any) => !existing.has(c.supplier_code))); } catch { /* skip */ } finally { setSuppSearchLoading(false); } }, [suppSearchKeyword, supplierItems]); // 실시간 검색 (2글자 이상) useEffect(() => { if (!suppSelectOpen) return; if (suppSearchKeyword.length > 0 && suppSearchKeyword.length < 2) return; searchSuppliers(); }, [suppSearchKeyword, suppSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 선택 → 상세 모달로 이동 const goToSuppDetail = () => { const selected = suppSearchResults.filter((c) => suppCheckedIds.has(c.id)); if (selected.length === 0) { toast.error("공급업체를 선택해주세요."); return; } setSelectedSuppsForDetail(selected); const mappings: typeof suppMappings = {}; const prices: typeof suppPrices = {}; for (const cust of selected) { const key = cust.supplier_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?.standard_price || "", discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", calculated_price: selectedItem?.standard_price || selectedItem?.standard_price || "", }]; } setSuppMappings(mappings); setSuppPrices(prices); setSuppSelectOpen(false); setSuppDetailOpen(true); }; const addMappingRow = (custKey: string) => { setSuppMappings((prev) => ({ ...prev, [custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, supplier_item_code: "", supplier_item_name: "" }], })); }; const removeMappingRow = (custKey: string, rowId: string) => { setSuppMappings((prev) => ({ ...prev, [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), })); }; const handleMappingDragEnd = (custKey: string, event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setSuppMappings((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 updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => { setSuppMappings((prev) => ({ ...prev, [custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), })); }; const addPriceRow = (custKey: string) => { setSuppPrices((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) => { setSuppPrices((prev) => ({ ...prev, [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), })); }; const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => { setSuppPrices((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 openEditSupp = async (row: any) => { const custKey = row.supplier_code || row.supplier_id; // supplier_mng에서 공급업체 정보 조회 let custInfo: any = { supplier_code: custKey, supplier_name: row.supplier_name || "" }; try { const res = await apiClient.post(`/table-management/tables/supplier_mng/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "supplier_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/${MAPPING_TABLE}/data`, { page: 1, size: 100, dataFilter: { enabled: true, filters: [ { columnName: "supplier_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.supplier_item_code || m.supplier_item_name) .map((m: any) => ({ _id: `m_existing_${m.id}`, supplier_item_code: m.supplier_item_code || "", supplier_item_name: m.supplier_item_name || "", })); } catch { /* skip */ } // 단가 전체 조회 let priceRows: any[] = []; try { const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { page: 1, size: 100, dataFilter: { enabled: true, filters: [ { columnName: "supplier_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: "", }); } setSelectedSuppsForDetail([custInfo]); setSuppMappings({ [custKey]: mappingRows }); setSuppPrices({ [custKey]: priceRows }); setEditSuppData(row); setSuppDetailOpen(true); }; const handleSuppDetailSave = async () => { if (!selectedItem) return; const isEditingExisting = !!editSuppData; setSaving(true); try { for (const cust of selectedSuppsForDetail) { const custKey = cust.supplier_code || cust.id; const mappingRows = suppMappings[custKey] || []; if (isEditingExisting && editSuppData?.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: "supplier_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 usedExistingIds = new Set(); let firstMappingId: string | null = editSuppData.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: { supplier_item_code: mappingRows[mi].supplier_item_code || "", supplier_item_name: mappingRows[mi].supplier_item_name || "", }, }); 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(), supplier_id: custKey, item_id: selectedItem.item_number, supplier_item_code: mappingRows[mi].supplier_item_code || "", supplier_item_name: mappingRows[mi].supplier_item_name || "", }); 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/supplier_item_prices/data`, { page: 1, size: 100, dataFilter: { enabled: true, filters: [ { columnName: "supplier_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: 인덱스 기반 const priceRows = (suppPrices[custKey] || []).filter((p) => p.base_price || p.start_date || p.currency_code || p.base_price_type ); const usedPriceIds = new Set(); for (let pi = 0; pi < priceRows.length; pi++) { const price = priceRows[pi]; const priceData = { mapping_id: firstMappingId || editSuppData.id, supplier_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/supplier_item_prices/edit`, { originalData: { id: existPrice.id }, updatedData: priceData, }); usedPriceIds.add(existPrice.id); } else { await apiClient.post(`/table-management/tables/supplier_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/supplier_item_prices/delete`, { data: toDeletePrices.map((p: any) => ({ id: p.id })), }); } } else { // 신규 등록 const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { id: crypto.randomUUID(), supplier_id: custKey, item_id: selectedItem.item_number, supplier_item_code: mappingRows[0]?.supplier_item_code || "", supplier_item_name: mappingRows[0]?.supplier_item_name || "", }); 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(), supplier_id: custKey, item_id: selectedItem.item_number, supplier_item_code: mappingRows[mi].supplier_item_code || "", supplier_item_name: mappingRows[mi].supplier_item_name || "", }); } const priceRows = (suppPrices[custKey] || []).filter((p) => p.base_price || p.start_date || p.currency_code || p.base_price_type ); for (const price of priceRows) { await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { id: crypto.randomUUID(), mapping_id: mappingId || "", supplier_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 ? "수정되었습니다." : `${selectedSuppsForDetail.length}개 공급업체가 추가되었습니다.`); setSuppDetailOpen(false); setEditSuppData(null); setSuppCheckedIds(new Set()); // 우측 새로고침 const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); } catch (err: any) { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); } }; // 우측: 공급업체 매핑 해제 (소프트 삭제 — item_id를 null 처리) const handleSupplierMappingDelete = async () => { if (supplierCheckedIds.length === 0) return; const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체의 연결을 해제하시겠습니까?`, { description: "해당 공급업체의 품목 연결이 해제됩니다. (데이터는 유지)", variant: "destructive", confirmText: "해제", }); if (!ok) return; try { const supplierCodes = supplierCheckedIds.map((mid) => { const group = Object.values(supplierGroups).find((g) => g.master.id === mid); return group?.master.supplier_id || group?.master.supplier_code || ""; }).filter(Boolean); for (const suppCode of supplierCodes) { // 해당 공급업체의 모든 매핑 조회 → item_id null 처리 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [ { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "supplier_id", operator: "equals", value: suppCode }, ]}, 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/supplier_item_prices/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [ { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "supplier_id", operator: "equals", value: suppCode }, ]}, autoFilter: true, }); const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; for (const p of prices) { await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, { originalData: { id: p.id }, updatedData: { item_id: null }, }); } } catch { /* skip */ } } toast.success(`${supplierCheckedIds.length}개 공급업체의 연결이 해제되었습니다.`); setSupplierCheckedIds([]); 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.unit, 기준단가: i.standard_price, 구매단가: i.standard_price, 통화: i.currency_code, 상태: i.status, })); await exportToExcel(data, "구매품목관리.xlsx", "구매품목"); toast.success("다운로드 완료"); }; // EDataTable 컬럼 정의 (구매품목) — ts.visibleColumns 기반 const COLUMN_RENDER_MAP: Record> = { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]" }, size: { width: "w-[80px]" }, unit: { width: "w-[60px]" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, currency_code: { width: "w-[50px]" }, status: { width: "w-[60px]" }, }; const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({ key: col.key, label: col.label, ...COLUMN_RENDER_MAP[col.key], })); return (
{/* 검색 필터 (DynamicSearchFilter) */} {/* 액션 버튼 영역 */}
{/* 마스터-디테일 분할 패널 */}
{/* 좌측: 구매품목 목록 */}
{/* 패널 헤더 */}
구매품목 목록 {itemCount}건
{/* 거래처 테이블 */} row.id} loading={itemLoading} emptyMessage="등록된 구매품목이 없어요" selectedId={selectedItemId} onSelect={(id) => setSelectedItemId(id)} onRowDoubleClick={(row) => openEditModal(row)} showRowNumber showPagination defaultPageSize={20} draggableColumns={false} columnOrderKey="c16-purchase-item" />
{/* 우측: 디테일 패널 */}
{!selectedItemId ? ( /* 빈 상태 */
품목을 선택해주세요
좌측에서 품목을 선택하면 공급업체 정보가 표시돼요
) : ( <> {/* 공급업체별 단가 헤더 */}
공급업체별 단가 {Object.keys(supplierGroups).length > 0 && ( {Object.keys(supplierGroups).length} )}
{/* 공급업체 테이블 (expandable rows) */}
0 && supplierCheckedIds.length === supplierItems.length} onChange={(e) => { if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); else setSupplierCheckedIds([]); }} /> 공급업체코드 공급업체명 공급업체품번 공급업체품명 기준가 단가 통화 {supplierLoading ? ( ) : Object.keys(supplierGroups).length === 0 ? ( 등록된 공급업체가 없어요 ) : Object.entries(supplierGroups).map(([custKey, group]) => { const isExpanded = expandedItems.has(custKey); const m = group.master; const isChecked = supplierCheckedIds.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={() => openEditSupp(m)} > { e.stopPropagation(); setSupplierCheckedIds((prev) => prev.includes(m.id) ? prev.filter((id) => id !== m.id) : [...prev, m.id] ); }} >
{isExpanded ? : } {m.supplier_code}
{m.supplier_name} {m.supplier_item_code} {m.supplier_item_name} {m.base_price ? Number(m.base_price).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); setFormData(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" ? ( setFormData((prev) => ({ ...prev, [field.key]: v }))} tableName={ITEM_TABLE} recordId={formData.id || ""} columnName={field.key} height="h-32" /> ) : field.type === "multi-category" ? ( setFormData((prev) => ({ ...prev, [field.key]: v }))} placeholder={`${field.label} 선택`} /> ) : field.type === "category" ? ( setFormData((prev) => ({ ...prev, [field.key]: v }))} placeholder={`${field.label} 선택`} /> ) : field.type === "textarea" ? (