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" ? ( +