From 51eddc6d8405323a79ba02e1f82afbe9e1211d5a Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 9 Apr 2026 11:52:40 +0900 Subject: [PATCH] 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: "",