From 6ef30f4e459ea7838d1ed16ef35b268bb3ac9dc1 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 9 Apr 2026 11:28:55 +0900 Subject: [PATCH 1/3] refactor: Update item number handling and improve sales order filtering - Changed item number field type from 'text' to 'numbering' in item info forms for better clarity. - Enhanced the logic for loading numbering previews, including handling manual input values and category changes. - Updated sales order page to filter items based on customer pricing rules, ensuring only relevant items are displayed. - Improved date handling in sales order page to ensure consistent formatting. These changes aim to enhance the user experience and data integrity across the application. --- .../COMPANY_10/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../(main)/COMPANY_10/sales/order/page.tsx | 32 ++- .../COMPANY_16/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../(main)/COMPANY_16/sales/order/page.tsx | 32 ++- .../COMPANY_29/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../(main)/COMPANY_29/sales/order/page.tsx | 32 ++- .../COMPANY_30/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../(main)/COMPANY_30/sales/order/page.tsx | 2 +- .../COMPANY_7/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../app/(main)/COMPANY_7/sales/order/page.tsx | 32 ++- .../COMPANY_8/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../app/(main)/COMPANY_8/sales/order/page.tsx | 32 ++- .../COMPANY_9/master-data/item-info/page.tsx | 220 ++++++++++++++++-- .../production/plan-management/page.tsx | 5 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 2 +- frontend/package-lock.json | 42 +--- 21 files changed, 1604 insertions(+), 172 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index f9a358d4..1cb49efb 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -249,7 +249,7 @@ export default function SalesOrderPage() { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } // 사용자 목록 try { @@ -608,6 +608,24 @@ export default function SalesOrderPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -615,6 +633,12 @@ export default function SalesOrderPage() { }); const resData = res.data?.data; let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + // 관리품목 필터 (코드/라벨 혼재 대응) if (itemSearchDivision !== "all") { const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; @@ -678,7 +702,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; @@ -768,7 +793,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; for (const p of prices) { if (p.start_date && p.start_date > today) continue; diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index f9a358d4..1cb49efb 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -249,7 +249,7 @@ export default function SalesOrderPage() { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } // 사용자 목록 try { @@ -608,6 +608,24 @@ export default function SalesOrderPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -615,6 +633,12 @@ export default function SalesOrderPage() { }); const resData = res.data?.data; let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + // 관리품목 필터 (코드/라벨 혼재 대응) if (itemSearchDivision !== "all") { const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; @@ -678,7 +702,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; @@ -768,7 +793,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; for (const p of prices) { if (p.start_date && p.start_date > today) continue; diff --git a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index f9a358d4..1cb49efb 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -249,7 +249,7 @@ export default function SalesOrderPage() { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } // 사용자 목록 try { @@ -608,6 +608,24 @@ export default function SalesOrderPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -615,6 +633,12 @@ export default function SalesOrderPage() { }); const resData = res.data?.data; let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + // 관리품목 필터 (코드/라벨 혼재 대응) if (itemSearchDivision !== "all") { const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; @@ -678,7 +702,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; @@ -768,7 +793,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; for (const p of prices) { if (p.start_date && p.start_date > today) continue; diff --git a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx index 6f4502bd..6fcbbe8f 100644 --- a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx @@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() { const custs = res.data?.data?.data || res.data?.data?.rows || []; optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, - label: `${c.customer_name} (${c.customer_code})`, + label: c.customer_name, })); } catch { /* skip */ } // 담당자 diff --git a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index f9a358d4..1cb49efb 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -249,7 +249,7 @@ export default function SalesOrderPage() { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } // 사용자 목록 try { @@ -608,6 +608,24 @@ export default function SalesOrderPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -615,6 +633,12 @@ export default function SalesOrderPage() { }); const resData = res.data?.data; let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + // 관리품목 필터 (코드/라벨 혼재 대응) if (itemSearchDivision !== "all") { const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; @@ -678,7 +702,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; @@ -768,7 +793,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; for (const p of prices) { if (p.start_date && p.start_date > today) continue; diff --git a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx index f9a358d4..1cb49efb 100644 --- a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx @@ -249,7 +249,7 @@ export default function SalesOrderPage() { page: 1, size: 500, autoFilter: true, }); const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } // 사용자 목록 try { @@ -608,6 +608,24 @@ export default function SalesOrderPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -615,6 +633,12 @@ export default function SalesOrderPage() { }); const resData = res.data?.data; let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + // 관리품목 필터 (코드/라벨 혼재 대응) if (itemSearchDivision !== "all") { const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; @@ -678,7 +702,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; for (const p of prices) { const start = p.start_date || ""; const end = p.end_date || ""; @@ -768,7 +793,8 @@ export default function SalesOrderPage() { autoFilter: true, }); const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; const priceMap: Record = {}; for (const p of prices) { if (p.start_date && p.start_date > today) continue; diff --git a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx index 375fd900..c208192e 100644 --- a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx @@ -1,6 +1,6 @@ "use client"; -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"; @@ -156,7 +156,7 @@ const GRID_COLUMNS = [ ]; const FORM_FIELDS = [ - { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { 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" }, @@ -209,6 +209,13 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + // 채번 관련 상태 + const [numberingRule, setNumberingRule] = useState(null); + const [numberingTemplate, setNumberingTemplate] = useState(""); + const [manualInputValue, setManualInputValue] = useState(""); + const [isNumberingLoading, setIsNumberingLoading] = useState(false); + const numberingRuleIdRef = useRef(null); + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -288,26 +295,49 @@ export default function ItemInfoPage() { }, [fetchItems]); // 채번 미리보기 로드 - const loadNumberingPreview = async () => { + const loadNumberingPreview = async (currentFormData?: Record, currentManualValue?: string) => { try { - const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); - const rule = ruleRes.data?.data; - if (rule?.ruleId) { - const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} }); - return previewRes.data?.data?.generatedCode || ""; + setIsNumberingLoading(true); + + // 규칙 조회 (캐싱) + let rule = numberingRule; + if (!rule) { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`); + rule = ruleRes.data?.data; + if (rule) { + setNumberingRule(rule); + numberingRuleIdRef.current = rule.ruleId; + } } + + if (!rule?.ruleId) return ""; + + // preview 호출 (formData + manualInputValue 전달) + const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { + formData: currentFormData || {}, + manualInputValue: currentManualValue || undefined, + }); + + const generatedCode = previewRes.data?.data?.generatedCode || ""; + setNumberingTemplate(generatedCode); + return generatedCode; } catch { /* 채번 규칙 없으면 무시 */ } + finally { + setIsNumberingLoading(false); + } return ""; }; // 등록 모달 열기 const openRegisterModal = async () => { setFormData({}); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); setIsModalOpen(true); - // 채번 컬럼 자동 로드 - const code = await loadNumberingPreview(); + // 채번 미리보기 + const code = await loadNumberingPreview({}); if (code) setFormData(prev => ({ ...prev, item_number: code })); }; @@ -315,6 +345,8 @@ export default function ItemInfoPage() { const openEditModal = (item: any) => { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -325,13 +357,72 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); + setManualInputValue(""); + setNumberingTemplate(""); setIsEditMode(false); setEditId(null); - const code = await loadNumberingPreview(); - if (code) setFormData(prev => ({ ...prev, item_number: code })); setIsModalOpen(true); + // 복사된 formData 기반으로 preview + const code = await loadNumberingPreview(rest); + if (code) setFormData(prev => ({ ...prev, item_number: code })); }; + // 카테고리 변경 시 채번 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 code = await loadNumberingPreview(formData, manualInputValue); + if (code) { + if (code.includes("____")) { + setNumberingTemplate(code); + const parts = code.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } else { + setFormData(prev => ({ ...prev, item_number: code })); + } + } + }, 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 (!numberingTemplate.includes("____")) return; + + const timer = setTimeout(async () => { + try { + const previewRes = await apiClient.post( + `/numbering-rules/${numberingRuleIdRef.current}/preview`, + { formData, manualInputValue: manualInputValue || undefined } + ); + const newCode = previewRes.data?.data?.generatedCode || ""; + if (newCode) { + setNumberingTemplate(newCode); + if (newCode.includes("____")) { + const parts = newCode.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); + } + } + } catch { /* ignore */ } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 저장 const handleSave = async () => { if (!formData.item_name) { @@ -342,6 +433,7 @@ export default function ItemInfoPage() { setSaving(true); try { if (isEditMode && editId) { + // 수정: item_number는 변경하지 않음 const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: editId }, @@ -349,8 +441,42 @@ export default function ItemInfoPage() { }); toast.success("수정되었어요."); } else { + // 신규 등록: allocateCode 호출하여 실제 순번 확보 + let finalItemNumber = formData.item_number || ""; + + if (numberingRuleIdRef.current) { + try { + const userInputCode = numberingTemplate.includes("____") + ? (() => { + const parts = numberingTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.slice(1).join("") || ""; + return prefix + manualInputValue + suffix; + })() + : 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/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields }); + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { + id: crypto.randomUUID(), + ...insertFields, + item_number: finalItemNumber, + }); toast.success("등록되었어요."); } setIsModalOpen(false); @@ -484,7 +610,15 @@ export default function ItemInfoPage() { /> {/* 등록/수정 모달 */} - + { + setIsModalOpen(open); + if (!open) { + setNumberingTemplate(""); + setManualInputValue(""); + setNumberingRule(null); + numberingRuleIdRef.current = null; + } + }}> {isEditMode ? "품목 수정" : "품목 등록"} @@ -534,6 +668,61 @@ export default function ItemInfoPage() { placeholder={field.label} rows={3} /> + ) : field.type === "numbering" ? ( + // 채번 세그먼트 UI + isEditMode ? ( + + ) : isNumberingLoading ? ( +
+ + 생성 중... +
+ ) : numberingTemplate.includes("____") ? ( + (() => { + const tplParts = numberingTemplate.split("____"); + const prefix = tplParts[0] || ""; + const suffix = tplParts.slice(1).join("") || ""; + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const val = e.target.value; + setManualInputValue(val); + setFormData(prev => ({ + ...prev, + item_number: prefix + val + suffix, + })); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" + /> + {suffix && ( + + {suffix} + + )} +
+ ); + })() + ) : ( + + ) ) : ["selling_price", "standard_price"].includes(field.key) ? ( setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} - disabled={field.disabled && !isEditMode} + placeholder={field.placeholder || field.label} className="h-9" /> )} diff --git a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx index 546ed739..50be10ee 100644 --- a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx @@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); diff --git a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx index 6f4502bd..6fcbbe8f 100644 --- a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx @@ -172,7 +172,7 @@ export default function JeilGlassOrderPage() { const custs = res.data?.data?.data || res.data?.data?.rows || []; optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, - label: `${c.customer_name} (${c.customer_code})`, + label: c.customer_name, })); } catch { /* skip */ } // 담당자 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78c7c6fd..4cdf2d22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -270,7 +270,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -312,7 +311,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -346,7 +344,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3062,7 +3059,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3722,7 +3718,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3817,7 +3812,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4161,7 +4155,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6662,7 +6655,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6673,7 +6665,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6716,7 +6707,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6799,7 +6789,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -7432,7 +7421,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8583,8 +8571,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -8906,7 +8893,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9688,7 +9674,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9777,7 +9762,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9879,7 +9863,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11036,7 +11019,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11817,8 +11799,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -13137,7 +13118,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13431,7 +13411,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13461,7 +13440,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13510,7 +13488,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13714,7 +13691,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13784,7 +13760,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13835,7 +13810,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13877,8 +13851,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -14186,7 +14159,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14209,8 +14181,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -15268,8 +15239,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -15357,7 +15327,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15706,7 +15675,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 51eddc6d8405323a79ba02e1f82afbe9e1211d5a Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 9 Apr 2026 11:52:40 +0900 Subject: [PATCH 2/3] refactor: Enhance numbering rule handling and BOM management - Updated the `NumberingRuleService` to support manual values in the computation of non-sequence values. - Improved the `ItemInfoPage` to parse preview codes into parts, allowing for better handling of manual inputs and dynamic code generation. - Refactored BOM management to streamline the retrieval of category options for both division and unit, enhancing data consistency. - Added utility functions to resolve unit labels from category options, improving the clarity of displayed data. These changes aim to improve the functionality and user experience in managing item information and BOM processes. --- .../src/services/numberingRuleService.ts | 19 +- .../COMPANY_10/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_10/production/bom/page.tsx | 63 ++-- .../COMPANY_16/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_16/production/bom/page.tsx | 67 +++-- .../COMPANY_29/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_29/production/bom/page.tsx | 63 ++-- .../COMPANY_30/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_30/production/bom/page.tsx | 63 ++-- .../COMPANY_7/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_7/production/bom/page.tsx | 66 ++-- .../COMPANY_8/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_8/production/bom/page.tsx | 64 ++-- .../COMPANY_9/master-data/item-info/page.tsx | 282 +++++++++++++----- .../(main)/COMPANY_9/production/bom/page.tsx | 63 ++-- 15 files changed, 1705 insertions(+), 737 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4a8be6bd..e682d5e7 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -391,12 +391,18 @@ class NumberingRuleService { */ private async computeNonSequenceValues( rule: NumberingRuleConfig, - formData?: Record + formData?: Record, + manualValues?: string[] ): Promise { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + let manualIndex = 0; return Promise.all(sortedParts.map(async (part: any) => { if (part.partType === "sequence") return ""; - if (part.generationMethod === "manual") return ""; + if (part.generationMethod === "manual") { + const val = manualValues?.[manualIndex] || ""; + manualIndex++; + return val; + } const autoConfig = part.autoConfig || {}; @@ -486,7 +492,8 @@ class NumberingRuleService { companyCode: string, ruleId: string, prefixKey: string, - formData?: Record + formData?: Record, + manualValues?: string[] ): Promise { // 1. 현재 저장된 카운터 조회 const currentCounter = await this.getSequenceForPrefix( @@ -499,7 +506,7 @@ class NumberingRuleService { if (rule.tableName && rule.columnName) { try { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); - const patternValues = await this.computeNonSequenceValues(rule, formData); + const patternValues = await this.computeNonSequenceValues(rule, formData, manualValues); const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || ""); if (psInfo) { @@ -1420,7 +1427,7 @@ class NumberingRuleService { if (rule.tableName && rule.columnName) { try { const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order); - const patternValues = await this.computeNonSequenceValues(rule, formData); + const patternValues = await this.computeNonSequenceValues(rule, formData, manualValues); const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || ""); if (psInfo) { @@ -1578,7 +1585,7 @@ class NumberingRuleService { let allocatedSequence = 0; if (hasSequence) { allocatedSequence = await this.resolveNextSequence( - client, rule, companyCode, ruleId, prefixKey, formData + client, rule, companyCode, ruleId, prefixKey, formData, extractedManualValues ); } diff --git a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1122,12 +1123,11 @@ export default function BomManagementPage() { } // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1193,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1214,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1239,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + // item_info의 division, unit 카테고리 + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1121,13 +1123,12 @@ export default function BomManagementPage() { } } - // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + // 현재 버전 ID 가져오기 (initialize-version 후 최신 값) + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1194,21 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + // 카테고리 코드 → 라벨 변환 (division + unit) + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1216,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1241,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1122,12 +1123,11 @@ export default function BomManagementPage() { } // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1193,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1214,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1239,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1122,12 +1123,11 @@ export default function BomManagementPage() { } // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1193,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1214,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1239,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + // item_info의 division, unit 카테고리 + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1121,13 +1123,12 @@ export default function BomManagementPage() { } } - // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + // 현재 버전 ID 가져오기 (initialize-version 후 최신 값) + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1194,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1215,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1240,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1121,13 +1122,11 @@ export default function BomManagementPage() { } } - // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1192,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1213,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1238,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", diff --git a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx index c208192e..19ef9dd9 100644 --- a/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx @@ -211,11 +211,121 @@ export default function ItemInfoPage() { // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); - const [numberingTemplate, setNumberingTemplate] = useState(""); + const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]); const [manualInputValue, setManualInputValue] = useState(""); const [isNumberingLoading, setIsNumberingLoading] = useState(false); const numberingRuleIdRef = useRef(null); + // 프리뷰 코드에서 각 파트별 표시값을 추출 + 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 || ""; + + // 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외) + 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" }; + } + // sequence, category: 알 수 없으므로 프리뷰 코드에서 추출 + default: return { known: false, sep, isManual: false, partType: part.partType }; + } + }); + + // 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출 + 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) { + // manual 파트: "____" 마커 찾아서 스킵 + 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 { + // 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색 + let endIdx = remaining.length; + + if (meta.sep) { + // 이 파트 뒤 구분자로 끝나는 지점 찾기 + // 단, 다음 파트의 시작을 기준으로 역방향 탐색 + if (nextMeta) { + // 다음 파트가 known이면 그 값이 시작되는 위치 탐색 + if (nextMeta.known && nextMeta.value) { + // 구분자 + 다음 값 패턴으로 찾기 + const pattern = meta.sep + nextMeta.value; + const patIdx = remaining.indexOf(pattern); + if (patIdx >= 0) endIdx = patIdx; + } else if (nextMeta.isManual) { + // 다음이 manual이면 구분자 + "____" 패턴 + const pattern = meta.sep + "____"; + const patIdx = remaining.indexOf(pattern); + 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; + }; + // 카테고리 옵션 로드 useEffect(() => { const loadCategories = async () => { @@ -310,7 +420,7 @@ export default function ItemInfoPage() { } } - if (!rule?.ruleId) return ""; + if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; // preview 호출 (formData + manualInputValue 전달) const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { @@ -319,26 +429,41 @@ export default function ItemInfoPage() { }); const generatedCode = previewRes.data?.data?.generatedCode || ""; - setNumberingTemplate(generatedCode); - return generatedCode; + // 파트별 표시값 추출 + const parts = parsePreviewIntoParts(generatedCode, rule); + setNumberingParts(parts); + return { code: generatedCode, parts }; } catch { /* 채번 규칙 없으면 무시 */ } finally { setIsNumberingLoading(false); } - return ""; + return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] }; + }; + + // 파트 값으로부터 전체 코드 조합 (수동 입력값 포함) + 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 openRegisterModal = async () => { setFormData({}); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 채번 미리보기 - const code = await loadNumberingPreview({}); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + 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 })); + } }; // 수정 모달 열기 @@ -346,7 +471,7 @@ export default function ItemInfoPage() { const raw = rawItems.find((r) => r.id === item.id) || item; setFormData({ ...raw }); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(true); setEditId(item.id); setIsModalOpen(true); @@ -358,13 +483,17 @@ export default function ItemInfoPage() { const { id, item_number, created_date, updated_date, writer, ...rest } = raw; setFormData(rest); setManualInputValue(""); - setNumberingTemplate(""); + setNumberingParts([]); setIsEditMode(false); setEditId(null); setIsModalOpen(true); // 복사된 formData 기반으로 preview - const code = await loadNumberingPreview(rest); - if (code) setFormData(prev => ({ ...prev, item_number: code })); + const result = await loadNumberingPreview(rest); + 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 })); + } }; // 카테고리 변경 시 채번 preview 재호출 @@ -377,17 +506,9 @@ export default function ItemInfoPage() { if (!hasCategoryPart) return; const timer = setTimeout(async () => { - const code = await loadNumberingPreview(formData, manualInputValue); - if (code) { - if (code.includes("____")) { - setNumberingTemplate(code); - const parts = code.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } else { - setFormData(prev => ({ ...prev, item_number: code })); - } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); } }, 300); @@ -398,25 +519,13 @@ export default function ItemInfoPage() { // 수동 입력값 변경 시 preview 갱신 (순번 재계산) useEffect(() => { if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return; - if (!numberingTemplate.includes("____")) return; + if (!numberingParts.some(p => p.isManual)) return; const timer = setTimeout(async () => { - try { - const previewRes = await apiClient.post( - `/numbering-rules/${numberingRuleIdRef.current}/preview`, - { formData, manualInputValue: manualInputValue || undefined } - ); - const newCode = previewRes.data?.data?.generatedCode || ""; - if (newCode) { - setNumberingTemplate(newCode); - if (newCode.includes("____")) { - const parts = newCode.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - setFormData(prev => ({ ...prev, item_number: prefix + manualInputValue + suffix })); - } - } - } catch { /* ignore */ } + const result = await loadNumberingPreview(formData, manualInputValue); + if (result.parts.length > 0) { + setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) })); + } }, 500); return () => clearTimeout(timer); @@ -446,13 +555,9 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { - const userInputCode = numberingTemplate.includes("____") - ? (() => { - const parts = numberingTemplate.split("____"); - const prefix = parts[0] || ""; - const suffix = parts.slice(1).join("") || ""; - return prefix + manualInputValue + suffix; - })() + const hasManual = numberingParts.some(p => p.isManual); + const userInputCode = hasManual + ? buildCodeFromParts(numberingParts, manualInputValue) : undefined; const allocRes = await apiClient.post( @@ -613,7 +718,7 @@ export default function ItemInfoPage() { { setIsModalOpen(open); if (!open) { - setNumberingTemplate(""); + setNumberingParts([]); setManualInputValue(""); setNumberingRule(null); numberingRuleIdRef.current = null; @@ -676,46 +781,59 @@ export default function ItemInfoPage() { disabled className="h-9 bg-muted" /> - ) : isNumberingLoading ? ( + ) : isNumberingLoading && numberingParts.length === 0 ? (
생성 중...
- ) : numberingTemplate.includes("____") ? ( - (() => { - const tplParts = numberingTemplate.split("____"); - const prefix = tplParts[0] || ""; - const suffix = tplParts.slice(1).join("") || ""; - return ( -
- {prefix && ( - - {prefix} + ) : 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} + )} + + ); + } + // auto 파트: 회색 배경 읽기전용 + return ( + + + {part.value} - )} - { - const val = e.target.value; - setManualInputValue(val); - setFormData(prev => ({ - ...prev, - item_number: prefix + val + suffix, - })); - }} - placeholder="입력" - className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none" - /> - {suffix && ( - - {suffix} - - )} -
- ); - })() + {part.separator && !isLast && ( + {part.separator} + )} + + ); + })} +
) : ( + // 전체 auto: 읽기전용 표시 0) { - const flatten = (items: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const item of items) { - result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); - if (item.children?.length) result.push(...flatten(item.children)); - } - return result; - }; - results["division"] = flatten(res.data.data); - } - } catch {} + for (const itemCol of ["division", "unit"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${itemCol}/values`); + if (res.data?.data?.length > 0) { + const flatten = (items: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const item of items) { + result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value }); + if (item.children?.length) result.push(...flatten(item.children)); + } + return result; + }; + results[itemCol] = flatten(res.data.data); + } + } catch {} + } setCategoryOptions(results); } catch {} @@ -1122,12 +1123,11 @@ export default function BomManagementPage() { } // 현재 버전 ID 가져오기 - let versionId = currentVersionId; - if (!versionId && bomId) { + let versionId: string | null = null; + if (bomId) { try { const verRes = await apiClient.get(`/bom/${bomId}/versions`); - const verData = verRes.data?.data || verRes.data; - versionId = verData?.currentVersionId || null; + versionId = verRes.data?.currentVersionId || null; } catch {} } @@ -1193,7 +1193,20 @@ export default function BomManagementPage() { }); rows = res2.data?.data?.data || res2.data?.data?.rows || []; } - setItemSearchResults(rows); + const resolved = rows.map((r: any) => { + const out = { ...r }; + if (out.division) { + out.division = out.division.split(",").map((c: string) => { + const t = c.trim(); + return categoryOptions["division"]?.find((o) => o.code === t)?.label || t; + }).filter((v: string) => v && v !== "s").join(", "); + } + if (out.unit) { + out.unit = categoryOptions["unit"]?.find((o) => o.code === out.unit)?.label || out.unit; + } + return out; + }); + setItemSearchResults(resolved); } catch { toast.error("품목 검색에 실패했어요"); } finally { @@ -1201,14 +1214,20 @@ export default function BomManagementPage() { } }; + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["unit"]?.find((o) => o.code === code)?.label || code; + }; + const selectItem = (item: any) => { + const unitLabel = resolveUnit(item.unit); if (itemSearchTarget === "master") { setMasterForm((prev) => ({ ...prev, item_id: item.id, item_code: item.item_number || "", item_name: item.item_name || "", - unit: item.unit || prev.unit, + unit: unitLabel || prev.unit, })); setShowItemSearchModal(false); } else { @@ -1220,7 +1239,7 @@ export default function BomManagementPage() { item_number: item.item_number || "", item_name: item.item_name || "", quantity: "1", - unit: item.unit || "", + unit: unitLabel, process_type: "", loss_rate: "0", remark: "", From a9d2df48bfae8bbfa7edb0779724e5196d4c4c07 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 9 Apr 2026 12:18:26 +0900 Subject: [PATCH 3/3] fix: Improve numbering rule handling and item routing functionality - Added temporary debug response in `numberingRuleController` for better troubleshooting. - Refactored SQL queries in `NumberingRuleService` to enhance parameter handling and improve clarity. - Updated `ItemInfoPage` to correctly handle manual input values for user-generated codes. - Implemented sorting logic in `ItemRoutingTab` to prioritize default routing versions and added functionality to set a version as default. These changes aim to enhance the reliability and user experience in managing numbering rules and item routing processes. --- .../controllers/numberingRuleController.ts | 3 ++ .../src/services/numberingRuleService.ts | 23 ++++++----- .../COMPANY_10/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_16/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_29/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_30/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_7/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_8/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ .../COMPANY_9/master-data/item-info/page.tsx | 4 +- .../process-info/ItemRoutingTab.tsx | 40 +++++++++++++++++++ 16 files changed, 309 insertions(+), 25 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 3764c3bc..2cd27c14 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -323,6 +323,9 @@ router.post( formData, manualInputValue ); + // TODO: 디버그용 임시 응답 (나중에 제거) + const { getPool } = require("../database/db"); + const dbPool = getPool(); return res.json({ success: true, data: { generatedCode: previewCode } }); } catch (error: any) { logger.error("코드 미리보기 실패", { error: error.message }); diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index e682d5e7..1179edbe 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -305,26 +305,26 @@ class NumberingRuleService { if (hasCompanyCode && companyCode !== "*") { sql = ` SELECT MAX( - CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + CAST(SUBSTRING("${columnName}" FROM ${seqStart} FOR ${seqLength}) AS INTEGER) ) as max_seq FROM "${tableName}" - WHERE "${columnName}" LIKE $3 - AND company_code = $4 - AND LENGTH("${columnName}") = $5 - AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + WHERE "${columnName}" LIKE $1 + AND company_code = $2 + AND LENGTH("${columnName}") = $3 + AND SUBSTRING("${columnName}" FROM ${seqStart} FOR ${seqLength}) ~ '^[0-9]+$' `; - params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length]; + params = [likePattern, companyCode, prefixLen + seqLength + codeSuffix.length]; } else { sql = ` SELECT MAX( - CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + CAST(SUBSTRING("${columnName}" FROM ${seqStart} FOR ${seqLength}) AS INTEGER) ) as max_seq FROM "${tableName}" - WHERE "${columnName}" LIKE $3 - AND LENGTH("${columnName}") = $4 - AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + WHERE "${columnName}" LIKE $1 + AND LENGTH("${columnName}") = $2 + AND SUBSTRING("${columnName}" FROM ${seqStart} FOR ${seqLength}) ~ '^[0-9]+$' `; - params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length]; + params = [likePattern, prefixLen + seqLength + codeSuffix.length]; } const result = await client.query(sql, params); @@ -1436,6 +1436,7 @@ class NumberingRuleService { psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode ); + if (maxFromTable > baseSeq) { logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", { ruleId, companyCode, currentSeq, maxFromTable, diff --git a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx index 19ef9dd9..ad55d2b2 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx @@ -556,8 +556,8 @@ export default function ItemInfoPage() { if (numberingRuleIdRef.current) { try { const hasManual = numberingParts.some(p => p.isManual); - const userInputCode = hasManual - ? buildCodeFromParts(numberingParts, manualInputValue) + const userInputCode = hasManual && manualInputValue + ? manualInputValue : undefined; const allocRes = await apiClient.post( diff --git a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx index a5b136c2..e50e27d5 100644 --- a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx @@ -39,6 +39,7 @@ import { type RoutingVersion, } from "@/lib/api/processInfo"; import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; function normalizeDefaultFlag(v: RoutingVersion): boolean { const raw = v.is_default as unknown; @@ -210,6 +211,7 @@ export function ItemRoutingTab() { return; } const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) })); + list.sort((a, b) => (a.is_default === b.is_default ? 0 : a.is_default ? -1 : 1)); setVersions(list); const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined; const def = list.find((v) => v.is_default); @@ -399,6 +401,38 @@ export function ItemRoutingTab() { } }; + // 선택 버전이 기본인지 + const selectedVersionIsDefault = useMemo(() => { + if (!selectedVersionId) return false; + const v = versions.find((v) => v.id === selectedVersionId); + return v ? normalizeDefaultFlag(v) : false; + }, [selectedVersionId, versions]); + + // 기본 라우팅으로 설정 + const handleSetDefaultVersion = async () => { + if (!selectedVersionId || !selectedItem) return; + try { + // 기존 기본 해제 + for (const v of versions) { + if (normalizeDefaultFlag(v) && v.id !== selectedVersionId) { + await apiClient.put(`/table-management/tables/item_routing_version/edit`, { + originalData: { id: v.id }, + updatedData: { is_default: false }, + }); + } + } + // 선택 버전 기본 설정 + await apiClient.put(`/table-management/tables/item_routing_version/edit`, { + originalData: { id: selectedVersionId }, + updatedData: { is_default: true }, + }); + toast.success("기본 라우팅으로 설정했어요"); + await loadVersions(selectedItem, selectedVersionId); + } catch { + toast.error("기본 설정에 실패했어요"); + } + }; + const submitNewVersion = async () => { if (!selectedItem) return; const name = versionName.trim(); @@ -639,6 +673,12 @@ export function ItemRoutingTab() {
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}
+ {selectedVersionId && !selectedVersionIsDefault && ( + + )}