From 1709ba6fbbdf4be03eedb856004d117529b999eb Mon Sep 17 00:00:00 2001 From: kjs Date: Sun, 12 Apr 2026 21:57:03 +0900 Subject: [PATCH] feat: Implement searchable category comboboxes and enhance item management - Added searchable category combobox and multi-category combobox components to improve item selection in the purchase and sales item pages. - Updated the supplier management page to utilize useCallback for item search, enhancing performance and responsiveness. - Implemented real-time search functionality for item selection, ensuring a smoother user experience. - Enhanced the handling of item mappings and prices, allowing for soft deletion of supplier connections while retaining data integrity. These changes aim to improve the overall user experience by providing more intuitive item management and selection processes across multiple company implementations. --- .../purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_10/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_10/sales/customer/page.tsx | 99 +- .../COMPANY_10/sales/sales-item/page.tsx | 1280 ++++++++++++--- .../purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_29/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_29/sales/customer/page.tsx | 99 +- .../COMPANY_29/sales/sales-item/page.tsx | 1280 ++++++++++++--- .../purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_30/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_30/sales/customer/page.tsx | 99 +- .../COMPANY_30/sales/sales-item/page.tsx | 1280 ++++++++++++--- .../COMPANY_7/purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_7/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_7/sales/customer/page.tsx | 99 +- .../COMPANY_7/sales/sales-item/page.tsx | 1249 +++++++++++--- .../COMPANY_8/purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_8/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_8/sales/customer/page.tsx | 99 +- .../COMPANY_8/sales/sales-item/page.tsx | 1280 ++++++++++++--- .../COMPANY_9/purchase/purchase-item/page.tsx | 1447 +++++++++++++---- .../COMPANY_9/purchase/supplier/page.tsx | 76 +- .../(main)/COMPANY_9/sales/customer/page.tsx | 99 +- .../COMPANY_9/sales/sales-item/page.tsx | 1280 ++++++++++++--- 24 files changed, 13602 insertions(+), 3779 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx index be24eb98..b7aad57e 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx @@ -9,23 +9,36 @@ * 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블) */ -import React, { useState, useEffect, useCallback } from "react"; +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 { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package } from "lucide-react"; +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 { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +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"; @@ -36,6 +49,121 @@ 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 ""; @@ -53,24 +181,58 @@ const ITEM_GRID_COLUMNS = [ { 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>({}); @@ -83,13 +245,12 @@ export default function PurchaseItemPage() { const [suppSearchLoading, setSuppSearchLoading] = useState(false); const [suppCheckedIds, setSuppCheckedIds] = useState>(new Set()); - // 품목 수정 모달 - const [editItemOpen, setEditItemOpen] = useState(false); - const [editItemForm, setEditItemForm] = useState>({}); const [saving, setSaving] = useState(false); // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); // 공급업체 상세 입력 모달 (공급업체 품번/품명 + 단가) @@ -117,12 +278,14 @@ export default function PurchaseItemPage() { } 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 */ } - } + 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); // 단가 카테고리 @@ -166,10 +329,10 @@ export default function PurchaseItemPage() { autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; - const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + setRawItems(raw); const data = raw.map((r: any) => { const converted = { ...r }; - for (const col of CATS) { + for (const col of CATEGORY_COLUMNS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } return converted; @@ -186,12 +349,265 @@ export default function PurchaseItemPage() { 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([]); setSupplierCheckedIds([]); return; } + if (!selectedItem?.item_number) { + setSupplierItems([]); + setSupplierGroups({}); + setSupplierCheckedIds([]); + return; + } setSupplierCheckedIds([]); const itemKey = selectedItem.item_number; const fetchSupplierItems = async () => { @@ -202,6 +618,7 @@ export default function PurchaseItemPage() { 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 || []; @@ -236,36 +653,57 @@ export default function PurchaseItemPage() { } catch { /* skip */ } } - // 4. 공급업체별 중복 제거 + 오늘 날짜 기준 단가 매칭 + // 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 sortedMappings = [...mappings].sort((a: any, b: any) => (a.supplier_id || "").localeCompare(b.supplier_id || "")); + const grouped: Record = {}; + const flatItems: any[] = []; - setSupplierItems(sortedMappings.map((m: any) => { + for (const m of mappings) { const custKey = m.supplier_id || ""; - const isFirstOfGroup = !seenCustIds.has(custKey); - if (custKey) seenCustIds.add(custKey); + if (seenCustIds.has(custKey)) continue; // 공급업체당 첫 매핑만 마스터 + seenCustIds.add(custKey); - const custPriceList = allPrices.filter((p: any) => p.supplier_id === 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] || {}; - return { + const masterRow = { ...m, - supplier_code: isFirstOfGroup ? custKey : "", - supplier_name: isFirstOfGroup ? (custMap[custKey]?.supplier_name || "") : "", + 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 { @@ -276,7 +714,7 @@ export default function PurchaseItemPage() { }, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 검색 - const searchSuppliers = async () => { + const searchSuppliers = useCallback(async () => { setSuppSearchLoading(true); try { const filters: any[] = []; @@ -291,7 +729,14 @@ export default function PurchaseItemPage() { 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 = () => { @@ -331,6 +776,17 @@ export default function PurchaseItemPage() { })); }; + 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, @@ -404,25 +860,53 @@ export default function PurchaseItemPage() { if (found) custInfo = found; } catch { /* skip */ } - const mappingRows = [{ - _id: `m_existing_${row.id}`, - supplier_item_code: row.supplier_item_code || "", - supplier_item_name: row.supplier_item_name || "", - }].filter((m) => m.supplier_item_code || m.supplier_item_name); + // 매핑 전체 조회 + 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 */ } - const priceRows = [{ - _id: `p_existing_${row.id}`, - start_date: row.start_date || "", - end_date: row.end_date || "", - currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", - base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "", - discount_value: row.discount_value ? String(row.discount_value) : "", - rounding_type: row.rounding_type || "", - rounding_unit_value: row.rounding_unit_value || "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - }].filter((p) => p.base_price || p.start_date); + // 단가 전체 조회 + 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({ @@ -449,45 +933,102 @@ export default function PurchaseItemPage() { const mappingRows = suppMappings[custKey] || []; if (isEditingExisting && editSuppData?.id) { - await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { - originalData: { id: editSuppData.id }, - updatedData: { - supplier_item_code: mappingRows[0]?.supplier_item_code || "", - supplier_item_name: mappingRows[0]?.supplier_item_name || "", - }, - }); + // 기존 매핑 조회 + 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 */ } - // 기존 prices 삭제 후 재등록 + // 매핑 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: "mapping_id", operator: "equals", value: editSuppData.id }, + { columnName: "supplier_id", operator: "equals", value: custKey }, + { columnName: "item_id", operator: "equals", value: selectedItem.item_number }, ]}, autoFilter: true, }); - const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; - if (existing.length > 0) { - await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { - data: existing.map((p: any) => ({ id: p.id })), - }); - } + existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; } catch { /* skip */ } + // 단가 upsert: 인덱스 기반 const priceRows = (suppPrices[custKey] || []).filter((p) => - (p.base_price && Number(p.base_price) > 0) || p.start_date + 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: editSuppData.id, - supplier_id: custKey, - item_id: selectedItem.item_number, + 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 { @@ -510,7 +1051,7 @@ export default function PurchaseItemPage() { } const priceRows = (suppPrices[custKey] || []).filter((p) => - (p.base_price && Number(p.base_price) > 0) || p.start_date + 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`, { @@ -519,6 +1060,7 @@ export default function PurchaseItemPage() { 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, @@ -541,70 +1083,63 @@ export default function PurchaseItemPage() { } }; - // 품목 수정 - const openEditItem = () => { - if (!selectedItem) return; - setEditItemForm({ ...selectedItem }); - setEditItemOpen(true); - }; - - const handleEditSave = async () => { - if (!editItemForm.id) return; - setSaving(true); - try { - await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { - originalData: { id: editItemForm.id }, - updatedData: { - standard_price: editItemForm.standard_price || null, - currency_code: editItemForm.currency_code || null, - }, - }); - toast.success("수정되었습니다."); - setEditItemOpen(false); - fetchItems(); - } 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: "삭제", + const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체의 연결을 해제하시겠습니까?`, { + description: "해당 공급업체의 품목 연결이 해제됩니다. (데이터는 유지)", + variant: "destructive", confirmText: "해제", }); if (!ok) return; try { - // 관련 단가 삭제 - for (const mappingId of supplierCheckedIds) { + 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: "mapping_id", operator: "equals", value: mappingId }] }, - autoFilter: true, + 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 || []; - if (prices.length > 0) { - await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { - data: prices.map((p: any) => ({ id: p.id })), + 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 */ } } - // 매핑 삭제 - await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { - data: supplierCheckedIds.map((id) => ({ id })), - }); - toast.success(`${supplierCheckedIds.length}개 공급업체 매핑이 삭제되었습니다.`); + + toast.success(`${supplierCheckedIds.length}개 공급업체의 연결이 해제되었습니다.`); setSupplierCheckedIds([]); const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); } catch { - toast.error("삭제에 실패했습니다."); + toast.error("연결 해제에 실패했습니다."); } }; @@ -649,8 +1184,23 @@ export default function PurchaseItemPage() { {/* 액션 버튼 영역 */}
-
- +
- {/* 공급업체 테이블 */} + {/* 공급업체 테이블 (expandable rows) */}
- {supplierLoading ? ( -
- -
- ) : supplierItems.length === 0 ? ( -
- 등록된 공급업체가 없어요 -
- ) : ( - - - - - 0 && supplierCheckedIds.length === supplierItems.length} - onChange={(e) => { - if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); - else setSupplierCheckedIds([]); - }} - /> - - 공급업체코드 - 공급업체명 - 공급업체품번 - 공급업체품명 - 기준가 - 단가 - 통화 +
+ + + + 0 && supplierCheckedIds.length === supplierItems.length} + onChange={(e) => { + if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); + else setSupplierCheckedIds([]); + }} + /> + + 공급업체코드 + 공급업체명 + 공급업체품번 + 공급업체품명 + 기준가 + 단가 + 통화 + + + + {supplierLoading ? ( + + + + - - - {supplierItems.map((row) => ( - openEditSupp(row)} - > - { - e.stopPropagation(); - setSupplierCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); + ) : 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)} > - - - {row.supplier_code} - {row.supplier_name} - {row.supplier_item_code} - {row.supplier_item_name} - - {row.base_price ? Number(row.base_price).toLocaleString() : ""} - - - {row.calculated_price ? Number(row.calculated_price).toLocaleString() : ""} - - {row.currency_code} - - ))} - -
- )} + { + 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} + +
+
+
+
+
+ ); + })()} + + ); + })} + +
)} @@ -825,73 +1477,151 @@ export default function PurchaseItemPage() {
- {/* ── 품목 수정 모달 ── */} - - - - 구매품목 수정 + {/* ── 품목 등록/수정 모달 ── */} + + + + {isEditMode ? "품목 수정" : "품목 등록"} - {editItemForm.item_number || ""} — {editItemForm.item_name || ""} + {isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."} -
- {/* 품목 기본정보 (읽기 전용) */} - {[ - { key: "item_number", label: "품목코드" }, - { key: "item_name", label: "품명" }, - { key: "size", label: "규격" }, - { key: "unit", label: "단위" }, - { key: "material", label: "재질" }, - { key: "status", label: "상태" }, - ].map((f) => ( -
- - -
- ))} -
- - {/* 구매 설정 (수정 가능) */} -
- - setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} - placeholder="구매단가를 입력해주세요" - className="h-9" - /> -
-
- - setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} - placeholder="기준단가를 입력해주세요" - className="h-9" - /> -
-
- - +
+
+ {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" ? ( +