From 6a4ebf362cbf0586fdc3bfed16af090d5ecc67f5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 22 Dec 2025 14:36:13 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat(UniversalFormModal):=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=ED=91=9C=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20ConfigPan?= =?UTF-8?q?el=EC=97=90=20showSaveButton=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EC=B2=B4=ED=81=AC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=8B=A8?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20SaveSettingsModal=20SelectItem=20key=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=B4=EA=B2=B0=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD=EC=A0=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalConfigPanel.tsx | 16 ++++++++ .../modals/SaveSettingsModal.tsx | 38 +++++++++---------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 4ef28d6f..aa2386be 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; @@ -334,6 +335,21 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 모달 창의 크기를 선택하세요 + {/* 저장 버튼 표시 설정 */} +
+
+ updateModalConfig({ showSaveButton: checked === true })} + /> + +
+ 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 +
+
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 2607cf83..c9976ed8 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -217,8 +217,8 @@ export function SaveSettingsModal({ const repeatSections = sections.filter((s) => s.repeatable); // 모든 필드 목록 (반복 섹션 포함) - const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => { - const fields: { columnName: string; label: string; sectionTitle: string }[] = []; + const getAllFields = (): { columnName: string; label: string; sectionTitle: string; sectionId: string }[] => { + const fields: { columnName: string; label: string; sectionTitle: string; sectionId: string }[] = []; sections.forEach((section) => { // 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined) if (section.fields && Array.isArray(section.fields)) { @@ -227,6 +227,7 @@ export function SaveSettingsModal({ columnName: field.columnName, label: field.label, sectionTitle: section.title, + sectionId: section.id, }); }); } @@ -550,8 +551,8 @@ export function SaveSettingsModal({ return ( - -
+
+
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"} @@ -560,19 +561,18 @@ export function SaveSettingsModal({ ({subTable.fieldMappings?.length || 0}개 매핑)
- -
- + + +
@@ -755,8 +755,8 @@ export function SaveSettingsModal({ - {allFields.map((field) => ( - + {allFields.map((field, fieldIndex) => ( + {field.label} ({field.sectionTitle}) ))} From 9493d81903f00a620eb11fd96993f76aeff0b668 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Dec 2025 16:39:46 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 79 +++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index eb230454..ac9768a1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -938,7 +938,9 @@ export class MenuCopyService { copiedCategoryMappings = await this.copyCategoryMappingsAndValues( menuObjids, menuIdMap, + sourceCompanyCode, targetCompanyCode, + Array.from(screenIds), userId, client ); @@ -2569,11 +2571,16 @@ export class MenuCopyService { /** * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) + * + * 화면에서 사용하는 table_name + column_name 조합을 기준으로 카테고리 값 복사 + * menu_objid 기준이 아닌 화면 컴포넌트 기준으로 복사하여 누락 방지 */ private async copyCategoryMappingsAndValues( menuObjids: number[], menuIdMap: Map, + sourceCompanyCode: string, targetCompanyCode: string, + screenIds: number[], userId: string, client: PoolClient ): Promise { @@ -2697,12 +2704,70 @@ export class MenuCopyService { ); } - // 4. 모든 원본 카테고리 값 한 번에 조회 + // 4. 화면에서 사용하는 카테고리 컬럼 조합 수집 + // 복사된 화면의 레이아웃에서 webType='category'인 컴포넌트의 tableName, columnName 추출 + const categoryColumnsResult = await client.query( + `SELECT DISTINCT + sl.properties->>'tableName' as table_name, + sl.properties->>'columnName' as column_name + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'webType' = 'category' + AND sl.properties->>'tableName' IS NOT NULL + AND sl.properties->>'columnName' IS NOT NULL`, + [screenIds] + ); + + // 카테고리 매핑에서 사용하는 table_name, column_name도 추가 + const mappingColumnsResult = await client.query( + `SELECT DISTINCT table_name, logical_column_name as column_name + FROM category_column_mapping + WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + // 두 결과 합치기 + const categoryColumns = new Set(); + for (const row of categoryColumnsResult.rows) { + if (row.table_name && row.column_name) { + categoryColumns.add(`${row.table_name}|${row.column_name}`); + } + } + for (const row of mappingColumnsResult.rows) { + if (row.table_name && row.column_name) { + categoryColumns.add(`${row.table_name}|${row.column_name}`); + } + } + + logger.info( + ` 📋 화면에서 사용하는 카테고리 컬럼: ${categoryColumns.size}개` + ); + + if (categoryColumns.size === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + // 5. 원본 회사의 카테고리 값 조회 (table_name + column_name 기준) + // menu_objid 조건 대신 table_name + column_name + 원본 회사 코드로 조회 + const columnConditions = Array.from(categoryColumns).map((col, i) => { + const [tableName, columnName] = col.split("|"); + return `(table_name = $${i * 2 + 2} AND column_name = $${i * 2 + 3})`; + }); + + const columnParams: string[] = []; + for (const col of categoryColumns) { + const [tableName, columnName] = col.split("|"); + columnParams.push(tableName, columnName); + } + const allValuesResult = await client.query( `SELECT * FROM table_column_category_values - WHERE menu_objid = ANY($1) + WHERE company_code = $1 + AND (${columnConditions.join(" OR ")}) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, - [menuObjids] + [sourceCompanyCode, ...columnParams] ); if (allValuesResult.rows.length === 0) { @@ -2710,6 +2775,8 @@ export class MenuCopyService { return copiedCount; } + logger.info(` 📋 원본 카테고리 값: ${allValuesResult.rows.length}개 발견`); + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code @@ -2763,8 +2830,12 @@ export class MenuCopyService { ) .join(", "); + // 기본 menu_objid: 매핑이 없을 경우 첫 번째 복사된 메뉴 사용 + const defaultMenuObjid = menuIdMap.values().next().value || 0; + const valueParams = values.flatMap((v) => { - const newMenuObjid = menuIdMap.get(v.menu_objid); + // 원본 menu_objid가 매핑에 있으면 사용, 없으면 기본값 사용 + const newMenuObjid = menuIdMap.get(v.menu_objid) ?? defaultMenuObjid; const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; From 99c09603254d725a5a482ecbd73b1b8a110c9a7b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 17:42:35 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=ED=8F=B0=ED=8A=B8=EA=B0=80=20=EC=9D=BC=EB=B6=80?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/SignatureGenerator.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/components/report/designer/SignatureGenerator.tsx b/frontend/components/report/designer/SignatureGenerator.tsx index 9a3bd29f..a36b0b8c 100644 --- a/frontend/components/report/designer/SignatureGenerator.tsx +++ b/frontend/components/report/designer/SignatureGenerator.tsx @@ -109,6 +109,22 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp }); } + // 사용자가 입력한 텍스트로 각 폰트의 글리프를 미리 로드 + const preloadCanvas = document.createElement("canvas"); + preloadCanvas.width = 500; + preloadCanvas.height = 200; + const preloadCtx = preloadCanvas.getContext("2d"); + + if (preloadCtx) { + for (const font of fonts) { + preloadCtx.font = `${font.weight} 124px ${font.style}`; + preloadCtx.fillText(name, 0, 100); + } + } + + // 글리프 로드 대기 (중요: 첫 렌더링 후 폰트가 완전히 로드되도록) + await new Promise((resolve) => setTimeout(resolve, 300)); + const newSignatures: string[] = []; // 동기적으로 하나씩 생성 From e1a032933dc6080a470dd094124dbf600a213d03 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:17:58 +0900 Subject: [PATCH 04/21] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=AC=EB=B0=B1?= =?UTF-8?q?=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=200=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerRightPanel.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index e3a24025..037125ee 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2589,12 +2589,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - top: Number(e.target.value), + top: Math.max(0, Number(e.target.value)), }, }) } @@ -2605,12 +2606,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - bottom: Number(e.target.value), + bottom: Math.max(0, Number(e.target.value)), }, }) } @@ -2621,12 +2623,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - left: Number(e.target.value), + left: Math.max(0, Number(e.target.value)), }, }) } @@ -2637,12 +2640,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - right: Number(e.target.value), + right: Math.max(0, Number(e.target.value)), }, }) } From 7875d8ab86c1b0d41c35a41788106a6b39b9528c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:20:16 +0900 Subject: [PATCH 05/21] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=201=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/report/designer/ReportDesignerRightPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 037125ee..bf401680 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2502,10 +2502,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - width: Number(e.target.value), + width: Math.max(1, Number(e.target.value)), }) } className="mt-1" @@ -2515,10 +2516,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - height: Number(e.target.value), + height: Math.max(1, Number(e.target.value)), }) } className="mt-1" From 533eaf5c9ff14bcb99965668b0e8e4b5d0ff7c5d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 23 Dec 2025 09:24:59 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat(TableSection):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EB=B6=80=EB=AA=A8=EA=B0=92=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?TableColumnConfig=EC=97=90=20receiveFromParent,=20parentFieldNa?= =?UTF-8?q?me=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=20allComponents?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=80=EB=AA=A8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=84=A4=EC=A0=95=EC=97=90=20"=EB=B6=80?= =?UTF-8?q?=EB=AA=A8=EA=B0=92"=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=20=ED=95=84=EB=93=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20UI=20=EC=B6=94=EA=B0=80=20handleAddItems()=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=EA=B0=92=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableSectionRenderer.tsx | 8 + .../UniversalFormModalConfigPanel.tsx | 206 ++++++++++++- .../modals/FieldDetailSettingsModal.tsx | 57 ++++ .../modals/SaveSettingsModal.tsx | 276 +++++++++++++----- .../modals/SectionLayoutModal.tsx | 226 ++++++++++++-- .../modals/TableSectionSettingsModal.tsx | 120 ++++++++ .../components/universal-form-modal/types.ts | 6 + 7 files changed, 805 insertions(+), 94 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 224459f0..047849b6 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -621,6 +621,14 @@ export function TableSectionRenderer({ if (col.defaultValue !== undefined && newItem[col.field] === undefined) { newItem[col.field] = col.defaultValue; } + + // 부모에서 값 받기 (receiveFromParent) + if (col.receiveFromParent) { + const parentField = col.parentFieldName || col.field; + if (formData[parentField] !== undefined) { + newItem[col.field] = formData[parentField]; + } + } } return newItem; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index aa2386be..b2d5a9da 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -48,13 +48,24 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); -export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFormModalConfigPanelProps) { +// 부모 화면에서 전달 가능한 필드 타입 +interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + +export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) { // 테이블 목록 const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tableColumns, setTableColumns] = useState<{ [tableName: string]: { name: string; type: string; label: string }[]; }>({}); + // 부모 화면에서 전달 가능한 필드 목록 + const [availableParentFields, setAvailableParentFields] = useState([]); + // 채번규칙 목록 const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]); @@ -72,6 +83,186 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor loadNumberingRules(); }, []); + // allComponents에서 부모 화면에서 전달 가능한 필드 추출 + useEffect(() => { + const extractParentFields = async () => { + if (!allComponents || allComponents.length === 0) { + setAvailableParentFields([]); + return; + } + + const fields: AvailableParentField[] = []; + + for (const comp of allComponents) { + // 컴포넌트 타입 추출 (여러 위치에서 확인) + const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; + const compConfig = comp.componentConfig || {}; + + // 1. TableList / InteractiveDataTable - 테이블 컬럼 추출 + if (compType === "table-list" || compType === "interactive-data-table") { + const tableName = compConfig.selectedTable || compConfig.tableName; + if (tableName) { + // 테이블 컬럼 로드 + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "TableList", + sourceTable: tableName, + }); + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 2. SplitPanelLayout2 - 데이터 전달 필드 및 소스 테이블 컬럼 추출 + if (compType === "split-panel-layout2") { + // dataTransferFields 추출 + const transferFields = compConfig.dataTransferFields; + if (transferFields && Array.isArray(transferFields)) { + transferFields.forEach((field: any) => { + if (field.targetColumn) { + fields.push({ + name: field.targetColumn, + label: field.targetColumn, + sourceComponent: "SplitPanelLayout2", + sourceTable: compConfig.leftPanel?.tableName, + }); + } + }); + } + + // 좌측 패널 테이블 컬럼도 추출 + const leftTableName = compConfig.leftPanel?.tableName; + if (leftTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${leftTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + // 중복 방지 + if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "SplitPanelLayout2 (좌측)", + sourceTable: leftTableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${leftTableName}):`, error); + } + } + } + + // 3. 기타 테이블 관련 컴포넌트 + if (compType === "card-display" || compType === "simple-repeater-table") { + const tableName = compConfig.tableName || compConfig.initialDataConfig?.sourceTable; + if (tableName) { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: compType, + sourceTable: tableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출 + if (compType === "button-primary" || compType === "button" || compType === "button-secondary") { + const action = compConfig.action || {}; + + // fieldMappings에서 소스 컬럼 추출 + const fieldMappings = action.fieldMappings || []; + fieldMappings.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (fieldMappings)", + sourceTable: action.sourceTableName, + }); + } + }); + + // dataMapping에서 소스 컬럼 추출 + const dataMapping = action.dataMapping || []; + dataMapping.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (dataMapping)", + sourceTable: action.sourceTableName, + }); + } + }); + } + } + + // 5. 현재 모달의 저장 테이블 컬럼도 추가 (부모에서 전달받을 수 있는 값들) + const currentTableName = config.saveConfig?.tableName; + if (currentTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "현재 폼 테이블", + sourceTable: currentTableName, + }); + } + }); + } + } catch (error) { + console.error(`현재 테이블 컬럼 로드 실패 (${currentTableName}):`, error); + } + } + + // 중복 제거 (같은 name이면 첫 번째만 유지) + const uniqueFields = fields.filter((field, index, self) => + index === self.findIndex(f => f.name === field.name) + ); + + setAvailableParentFields(uniqueFields); + }; + + extractParentFields(); + }, [allComponents, config.saveConfig?.tableName]); + // 저장 테이블 변경 시 컬럼 로드 useEffect(() => { if (config.saveConfig.tableName) { @@ -85,9 +276,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { setTables( - data.map((t: { tableName?: string; table_name?: string; tableLabel?: string; table_label?: string }) => ({ + data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({ name: t.tableName || t.table_name || "", - label: t.tableLabel || t.table_label || t.tableName || t.table_name || "", + // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명 + label: t.displayName || t.tableLabel || t.table_label || "", })), ); } @@ -620,6 +812,12 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor setSelectedField(field); setFieldDetailModalOpen(true); }} + tableName={config.saveConfig.tableName} + tableColumns={tableColumns[config.saveConfig.tableName || ""]?.map(col => ({ + name: col.name, + type: col.type, + label: col.label || col.name + })) || []} /> )} @@ -666,6 +864,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor tableColumns={tableColumns} numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} + availableParentFields={availableParentFields} /> )} @@ -706,6 +905,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} onLoadTableColumns={loadTableColumns} allSections={config.sections as FormSectionConfig[]} + availableParentFields={availableParentFields} /> )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index d53d6e00..c8584a5f 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -36,6 +36,17 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +/** + * 부모 화면에서 전달 가능한 필드 타입 + * 유니버셜 폼 모달에서 "부모에서 값 받기" 설정 시 선택 가능한 필드 목록 + */ +export interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + interface FieldDetailSettingsModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -45,6 +56,8 @@ interface FieldDetailSettingsModalProps { tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] }; numberingRules: { id: string; name: string }[]; onLoadTableColumns: (tableName: string) => void; + // 부모 화면에서 전달 가능한 필드 목록 (선택사항) + availableParentFields?: AvailableParentField[]; } export function FieldDetailSettingsModal({ @@ -56,6 +69,7 @@ export function FieldDetailSettingsModal({ tableColumns, numberingRules, onLoadTableColumns, + availableParentFields = [], }: FieldDetailSettingsModalProps) { // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); @@ -293,6 +307,49 @@ export function FieldDetailSettingsModal({ />
부모 화면에서 전달받은 값으로 자동 채워집니다 + + {/* 부모에서 값 받기 활성화 시 필드 선택 */} + {localField.receiveFromParent && ( +
+ + {availableParentFields.length > 0 ? ( + + ) : ( +
+ updateField({ parentFieldName: e.target.value })} + placeholder={`예: ${localField.columnName || "parent_field_name"}`} + className="h-8 text-xs" + /> +

+ 부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다. +

+
+ )} +
+ )}
{/* Accordion으로 고급 설정 */} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index c9976ed8..5be176dc 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Plus, Trash2, Database, Layers, Info } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, Database, Layers, Info, Check, ChevronsUpDown } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types"; @@ -50,6 +52,11 @@ export function SaveSettingsModal({ saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single" ); + // 테이블 검색 Popover 상태 + const [singleTableSearchOpen, setSingleTableSearchOpen] = useState(false); + const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false); + const [subTableSearchOpen, setSubTableSearchOpen] = useState>({}); + // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { @@ -376,24 +383,68 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + onLoadTableColumns(t.name); + setSingleTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
폼 데이터를 저장할 테이블을 선택하세요
@@ -426,37 +477,81 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ + customApiSave: { + ...localSaveConfig.customApiSave, + apiType: "multi-table", + multiTable: { + ...localSaveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...localSaveConfig.customApiSave?.multiTable?.mainTable, + tableName: t.name, + }, + }, + }, + }); + onLoadTableColumns(t.name); + setMainTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
주요 데이터를 저장할 메인 테이블 (예: orders, user_info)
@@ -576,24 +671,71 @@ export function SaveSettingsModal({
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSubTable(subIndex, { tableName: t.name }); + onLoadTableColumns(t.name); + setSubTableSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+ 반복 데이터를 저장할 서브 테이블
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index 4a90a777..b47c2424 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; -import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types"; import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config"; @@ -21,12 +23,22 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 테이블 컬럼 정보 타입 +interface TableColumnInfo { + name: string; + type: string; + label: string; +} + interface SectionLayoutModalProps { open: boolean; onOpenChange: (open: boolean) => void; section: FormSectionConfig; onSave: (updates: Partial) => void; onOpenFieldDetail: (field: FormFieldConfig) => void; + // 저장 테이블의 컬럼 정보 + tableName?: string; + tableColumns?: TableColumnInfo[]; } export function SectionLayoutModal({ @@ -35,8 +47,13 @@ export function SectionLayoutModal({ section, onSave, onOpenFieldDetail, + tableName = "", + tableColumns = [], }: SectionLayoutModalProps) { + // 컬럼 선택 Popover 상태 (필드별) + const [columnSearchOpen, setColumnSearchOpen] = useState>({}); + // 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화) const [localSection, setLocalSection] = useState(() => ({ ...section, @@ -443,11 +460,90 @@ export function SectionLayoutModal({
- updateField(field.id, { columnName: e.target.value })} - className="h-6 text-[9px] mt-0.5" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [field.id]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + updateField(field.id, { + columnName: col.name, + // 라벨이 기본값이면 컬럼 라벨로 자동 설정 + ...(field.label.startsWith("새 필드") || field.label.startsWith("field_") + ? { label: col.label || col.name } + : {}) + }); + setColumnSearchOpen(prev => ({ ...prev, [field.id]: false })); + }} + className="text-xs" + > + +
+
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+ {tableName && ( + {tableName} + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + updateField(field.id, { columnName: e.target.value })} + className="h-6 text-[9px] mt-0.5" + placeholder="저장 테이블을 먼저 설정하세요" + /> + )}
@@ -821,24 +917,106 @@ export function SectionLayoutModal({ className="h-5 text-[8px]" placeholder="라벨" /> - { - const newGroups = localSection.optionalFieldGroups?.map((g) => - g.id === group.id - ? { - ...g, - fields: g.fields.map((f) => - f.id === field.id ? { ...f, columnName: e.target.value } : f - ), - } - : g - ); - updateSection({ optionalFieldGroups: newGroups }); - }} - className="h-5 text-[8px]" - placeholder="컬럼명" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id + ? { + ...f, + columnName: col.name, + ...(f.label.startsWith("필드 ") ? { label: col.label || col.name } : {}) + } + : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: false })); + }} + className="text-[9px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id ? { ...f, columnName: e.target.value } : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="h-5 text-[8px]" + placeholder="컬럼명" + /> + )} onUpdate({ parentFieldName: e.target.value })} + placeholder={col.field} + className="h-8 text-xs" + /> +

+ 비워두면 "{col.field}"를 사용합니다. +

+ + )} + + )} + {/* 조회 설정 (조회 ON일 때만 표시) */} {col.lookup?.enabled && (
@@ -1119,6 +1235,8 @@ interface TableSectionSettingsModalProps { onLoadCategoryList?: () => void; // 전체 섹션 목록 (다른 섹션 필드 참조용) allSections?: FormSectionConfig[]; + // 부모 화면에서 전달 가능한 필드 목록 + availableParentFields?: AvailableParentField[]; } export function TableSectionSettingsModal({ @@ -1132,6 +1250,7 @@ export function TableSectionSettingsModal({ categoryList = [], onLoadCategoryList, allSections = [], + availableParentFields = [], }: TableSectionSettingsModalProps) { // 로컬 상태 const [title, setTitle] = useState(section.title); @@ -1693,6 +1812,7 @@ export function TableSectionSettingsModal({ sections={otherSections} formFields={otherSectionFields} tableConfig={tableConfig} + availableParentFields={availableParentFields} onLoadTableColumns={onLoadTableColumns} onUpdate={(updates) => updateColumn(index, updates)} onMoveUp={() => moveColumn(index, "up")} diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 4e25f7d7..935d46be 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -335,6 +335,10 @@ export interface TableColumnConfig { // 날짜 일괄 적용 (type이 "date"일 때만 사용) // 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨 batchApply?: boolean; + + // 부모에서 값 받기 (모든 행에 동일한 값 적용) + receiveFromParent?: boolean; // 부모에서 값 받기 활성화 + parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일) } // ============================================ @@ -705,6 +709,8 @@ export interface UniversalFormModalComponentProps { export interface UniversalFormModalConfigPanelProps { config: UniversalFormModalConfig; onChange: (config: UniversalFormModalConfig) => void; + // 화면 설계 시 같은 화면의 다른 컴포넌트들 (부모 데이터 필드 추출용) + allComponents?: any[]; } // 필드 타입 옵션 From 5f406fbe888157c9df7f7f4a330719cfcd0a9bf6 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Dec 2025 09:31:18 +0900 Subject: [PATCH 07/21] =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/commonCodeController.ts | 145 +++- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/routes/commonCodeRoutes.ts | 15 + .../src/services/commonCodeService.ts | 207 +++++- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + .../admin/cascading-management/page.tsx | 5 +- .../tabs/HierarchyColumnTab.tsx | 626 ++++++++++++++++++ frontend/app/(main)/admin/tableMng/page.tsx | 129 +++- frontend/components/admin/CodeDetailPanel.tsx | 188 +++++- frontend/components/admin/CodeFormModal.tsx | 211 +++--- .../components/admin/SortableCodeItem.tsx | 218 ++++-- .../common/HierarchicalCodeSelect.tsx | 457 +++++++++++++ .../common/MultiColumnHierarchySelect.tsx | 389 +++++++++++ frontend/components/unified/UnifiedDate.tsx | 488 ++++++++++++++ .../config-panels/UnifiedInputConfigPanel.tsx | 152 +++++ frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/queries/useCodes.ts | 147 +++- frontend/hooks/useAutoFill.ts | 1 + frontend/lib/api/commonCode.ts | 58 ++ .../select-basic/SelectBasicComponent.tsx | 275 ++++++-- .../select-basic/SelectBasicConfigPanel.tsx | 392 +++++++---- .../registry/components/select-basic/types.ts | 18 + frontend/lib/schemas/commonCode.ts | 11 +- frontend/types/commonCode.ts | 8 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 32 files changed, 3673 insertions(+), 478 deletions(-) create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx create mode 100644 frontend/components/common/HierarchicalCodeSelect.tsx create mode 100644 frontend/components/common/MultiColumnHierarchySelect.tsx create mode 100644 frontend/components/unified/UnifiedDate.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedInputConfigPanel.tsx diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index b0db2059..5b6b1453 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -94,7 +94,9 @@ export class CommonCodeController { sortOrder: code.sort_order, isActive: code.is_active, useYn: code.is_active, - companyCode: code.company_code, // 추가 + companyCode: code.company_code, + parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값 + depth: code.depth, // 계층구조: 깊이 // 기존 필드명도 유지 (하위 호환성) code_category: code.code_category, @@ -103,7 +105,9 @@ export class CommonCodeController { code_name_eng: code.code_name_eng, sort_order: code.sort_order, is_active: code.is_active, - company_code: code.company_code, // 추가 + company_code: code.company_code, + parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값 + // depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일) created_date: code.created_date, created_by: code.created_by, updated_date: code.updated_date, @@ -286,19 +290,17 @@ export class CommonCodeController { }); } - if (!menuObjid) { - return res.status(400).json({ - success: false, - message: "메뉴 OBJID는 필수입니다.", - }); - } + // menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드) + // 공통코드관리 메뉴 OBJID: 1757401858940 + const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940; + const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID; const code = await this.commonCodeService.createCode( categoryCode, codeData, userId, companyCode, - Number(menuObjid) + effectiveMenuObjid ); return res.status(201).json({ @@ -588,4 +590,129 @@ export class CommonCodeController { }); } } + + /** + * 계층구조 코드 조회 + * GET /api/common-codes/categories/:categoryCode/hierarchy + * Query: parentCodeValue (optional), depth (optional), menuObjid (optional) + */ + async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { parentCodeValue, depth, menuObjid } = req.query; + const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; + + // parentCodeValue가 빈 문자열이면 최상위 코드 조회 + const parentValue = parentCodeValue === '' || parentCodeValue === undefined + ? null + : parentCodeValue as string; + + const codes = await this.commonCodeService.getHierarchicalCodes( + categoryCode, + parentValue, + depth ? parseInt(depth as string) : undefined, + userCompanyCode, + menuObjidNum + ); + + // 프론트엔드 형식으로 변환 + const transformedData = codes.map((code: any) => ({ + codeValue: code.code_value, + codeName: code.code_name, + codeNameEng: code.code_name_eng, + description: code.description, + sortOrder: code.sort_order, + isActive: code.is_active, + parentCodeValue: code.parent_code_value, + depth: code.depth, + // 기존 필드도 유지 + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + sort_order: code.sort_order, + is_active: code.is_active, + parent_code_value: code.parent_code_value, + })); + + return res.json({ + success: true, + data: transformedData, + message: `계층구조 코드 조회 성공 (${categoryCode})`, + }); + } catch (error) { + logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "계층구조 코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 코드 트리 조회 + * GET /api/common-codes/categories/:categoryCode/tree + */ + async getCodeTree(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { menuObjid } = req.query; + const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; + + const result = await this.commonCodeService.getCodeTree( + categoryCode, + userCompanyCode, + menuObjidNum + ); + + return res.json({ + success: true, + data: result, + message: `코드 트리 조회 성공 (${categoryCode})`, + }); + } catch (error) { + logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "코드 트리 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 자식 코드 존재 여부 확인 + * GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children + */ + async hasChildren(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode, codeValue } = req.params; + const companyCode = req.user?.companyCode; + + const hasChildren = await this.commonCodeService.hasChildren( + categoryCode, + codeValue, + companyCode + ); + + return res.json({ + success: true, + data: { hasChildren }, + message: "자식 코드 확인 완료", + }); + } catch (error) { + logger.error( + `자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`, + error + ); + return res.status(500).json({ + success: false, + message: "자식 코드 확인 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 92036080..a5107448 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index ed11d3d1..22cd2d2b 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -50,3 +50,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index d74929cb..79a1c6e8 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -66,3 +66,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index ce2fbcac..352a05b5 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 6772a6e9..d1205e51 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -46,6 +46,21 @@ router.put("/categories/:categoryCode/codes/reorder", (req, res) => commonCodeController.reorderCodes(req, res) ); +// 계층구조 코드 조회 (구체적인 경로를 먼저 배치) +router.get("/categories/:categoryCode/hierarchy", (req, res) => + commonCodeController.getHierarchicalCodes(req, res) +); + +// 코드 트리 조회 +router.get("/categories/:categoryCode/tree", (req, res) => + commonCodeController.getCodeTree(req, res) +); + +// 자식 코드 존재 여부 확인 +router.get("/categories/:categoryCode/codes/:codeValue/has-children", (req, res) => + commonCodeController.hasChildren(req, res) +); + router.put("/categories/:categoryCode/codes/:codeValue", (req, res) => commonCodeController.updateCode(req, res) ); diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index db19adc3..7c0d917a 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -25,6 +25,8 @@ export interface CodeInfo { is_active: string; company_code: string; menu_objid?: number | null; // 메뉴 기반 코드 관리용 + parent_code_value?: string | null; // 계층구조: 부모 코드값 + depth?: number; // 계층구조: 깊이 (1, 2, 3단계) created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -61,6 +63,8 @@ export interface CreateCodeData { description?: string; sortOrder?: number; isActive?: string; + parentCodeValue?: string; // 계층구조: 부모 코드값 + depth?: number; // 계층구조: 깊이 (1, 2, 3단계) } export class CommonCodeService { @@ -405,11 +409,22 @@ export class CommonCodeService { menuObjid: number ) { try { + // 계층구조: depth 계산 (부모가 있으면 부모의 depth + 1, 없으면 1) + let depth = 1; + if (data.parentCodeValue) { + const parentCode = await queryOne( + `SELECT depth FROM code_info + WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, + [categoryCode, data.parentCodeValue, companyCode] + ); + depth = parentCode ? (parentCode.depth || 1) + 1 : 1; + } + const code = await queryOne( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, - is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW()) + is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW()) RETURNING *`, [ categoryCode, @@ -420,13 +435,15 @@ export class CommonCodeService { data.sortOrder || 0, menuObjid, companyCode, + data.parentCodeValue || null, + depth, createdBy, createdBy, ] ); logger.info( - `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})` + `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})` ); return code; } catch (error) { @@ -491,6 +508,24 @@ export class CommonCodeService { updateFields.push(`is_active = $${paramIndex++}`); values.push(activeValue); } + // 계층구조: 부모 코드값 수정 + if (data.parentCodeValue !== undefined) { + updateFields.push(`parent_code_value = $${paramIndex++}`); + values.push(data.parentCodeValue || null); + + // depth도 함께 업데이트 + let newDepth = 1; + if (data.parentCodeValue) { + const parentCode = await queryOne( + `SELECT depth FROM code_info + WHERE code_category = $1 AND code_value = $2`, + [categoryCode, data.parentCodeValue] + ); + newDepth = parentCode ? (parentCode.depth || 1) + 1 : 1; + } + updateFields.push(`depth = $${paramIndex++}`); + values.push(newDepth); + } // WHERE 절 구성 let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`; @@ -847,4 +882,170 @@ export class CommonCodeService { throw error; } } + + /** + * 계층구조 코드 조회 (특정 depth 또는 부모코드 기준) + * @param categoryCode 카테고리 코드 + * @param parentCodeValue 부모 코드값 (없으면 최상위 코드만 조회) + * @param depth 특정 깊이만 조회 (선택) + */ + async getHierarchicalCodes( + categoryCode: string, + parentCodeValue?: string | null, + depth?: number, + userCompanyCode?: string, + menuObjid?: number + ) { + try { + const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"]; + const values: any[] = [categoryCode]; + let paramIndex = 2; + + // 부모 코드값 필터링 + if (parentCodeValue === null || parentCodeValue === undefined) { + // 최상위 코드 (부모가 없는 코드) + whereConditions.push("(parent_code_value IS NULL OR parent_code_value = '')"); + } else if (parentCodeValue !== '') { + whereConditions.push(`parent_code_value = $${paramIndex}`); + values.push(parentCodeValue); + paramIndex++; + } + + // 특정 깊이 필터링 + if (depth !== undefined) { + whereConditions.push(`depth = $${paramIndex}`); + values.push(depth); + paramIndex++; + } + + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + } + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const codes = await query( + `SELECT * FROM code_info + ${whereClause} + ORDER BY sort_order ASC, code_value ASC`, + values + ); + + logger.info( + `계층구조 코드 조회: ${categoryCode}, 부모: ${parentCodeValue || '최상위'}, 깊이: ${depth || '전체'} - ${codes.length}개` + ); + + return codes; + } catch (error) { + logger.error(`계층구조 코드 조회 중 오류 (${categoryCode}):`, error); + throw error; + } + } + + /** + * 계층구조 코드 트리 전체 조회 (카테고리 기준) + */ + async getCodeTree( + categoryCode: string, + userCompanyCode?: string, + menuObjid?: number + ) { + try { + const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"]; + const values: any[] = [categoryCode]; + let paramIndex = 2; + + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + } + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const allCodes = await query( + `SELECT * FROM code_info + ${whereClause} + ORDER BY depth ASC, sort_order ASC, code_value ASC`, + values + ); + + // 트리 구조로 변환 + const buildTree = (codes: CodeInfo[], parentValue: string | null = null): any[] => { + return codes + .filter(code => { + const codeParent = code.parent_code_value || null; + return codeParent === parentValue; + }) + .map(code => ({ + ...code, + children: buildTree(codes, code.code_value) + })); + }; + + const tree = buildTree(allCodes); + + logger.info( + `코드 트리 조회 완료: ${categoryCode} - 전체 ${allCodes.length}개` + ); + + return { + flat: allCodes, + tree + }; + } catch (error) { + logger.error(`코드 트리 조회 중 오류 (${categoryCode}):`, error); + throw error; + } + } + + /** + * 자식 코드가 있는지 확인 + */ + async hasChildren( + categoryCode: string, + codeValue: string, + companyCode?: string + ): Promise { + try { + let sql = `SELECT COUNT(*) as count FROM code_info + WHERE code_category = $1 AND parent_code_value = $2`; + const values: any[] = [categoryCode, codeValue]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $3`; + values.push(companyCode); + } + + const result = await queryOne<{ count: string }>(sql, values); + const count = parseInt(result?.count || "0"); + + return count > 0; + } catch (error) { + logger.error(`자식 코드 확인 중 오류 (${categoryCode}.${codeValue}):`, error); + throw error; + } + } } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c2c44be0..c9349b94 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -586,3 +586,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 4ffb7655..42900211 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -359,3 +359,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 1de42fb2..c392eece 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -345,3 +345,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 5b5f6b37..c36d8ae0 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -12,6 +12,7 @@ import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; +import HierarchyColumnTab from "./tabs/HierarchyColumnTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -21,7 +22,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); diff --git a/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx new file mode 100644 index 00000000..d0d77230 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx @@ -0,0 +1,626 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react"; +import { toast } from "sonner"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { + hierarchyColumnApi, + HierarchyColumnGroup, + CreateHierarchyGroupRequest, +} from "@/lib/api/hierarchyColumn"; +import { commonCodeApi } from "@/lib/api/commonCode"; +import apiClient from "@/lib/api/client"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + displayName?: string; + dataType?: string; +} + +interface CategoryInfo { + categoryCode: string; + categoryName: string; +} + +export default function HierarchyColumnTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [isEditing, setIsEditing] = useState(false); + + // 폼 상태 + const [formData, setFormData] = useState({ + groupCode: "", + groupName: "", + description: "", + codeCategory: "", + tableName: "", + maxDepth: 3, + mappings: [ + { depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true }, + { depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false }, + { depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false }, + ], + }); + + // 참조 데이터 + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [categories, setCategories] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingCategories, setLoadingCategories] = useState(false); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const response = await hierarchyColumnApi.getAll(); + if (response.success && response.data) { + setGroups(response.data); + } else { + toast.error(response.error || "계층구조 그룹 로드 실패"); + } + } catch (error) { + console.error("계층구조 그룹 로드 에러:", error); + toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + setLoadingTables(true); + try { + const response = await apiClient.get("/table-management/tables"); + if (response.data?.success && response.data?.data) { + setTables(response.data.data); + } + } catch (error) { + console.error("테이블 로드 에러:", error); + } finally { + setLoadingTables(false); + } + }, []); + + // 카테고리 목록 로드 + const loadCategories = useCallback(async () => { + setLoadingCategories(true); + try { + const response = await commonCodeApi.categories.getList(); + if (response.success && response.data) { + setCategories( + response.data.map((cat: any) => ({ + categoryCode: cat.categoryCode || cat.category_code, + categoryName: cat.categoryName || cat.category_name, + })) + ); + } + } catch (error) { + console.error("카테고리 로드 에러:", error); + } finally { + setLoadingCategories(false); + } + }, []); + + // 테이블 선택 시 컬럼 로드 + const loadColumns = useCallback(async (tableName: string) => { + if (!tableName) { + setColumns([]); + return; + } + setLoadingColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data?.success && response.data?.data) { + setColumns(response.data.data); + } + } catch (error) { + console.error("컬럼 로드 에러:", error); + } finally { + setLoadingColumns(false); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + loadCategories(); + }, [loadGroups, loadTables, loadCategories]); + + // 테이블 선택 변경 시 컬럼 로드 + useEffect(() => { + if (formData.tableName) { + loadColumns(formData.tableName); + } + }, [formData.tableName, loadColumns]); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + groupCode: "", + groupName: "", + description: "", + codeCategory: "", + tableName: "", + maxDepth: 3, + mappings: [ + { depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true }, + { depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false }, + { depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false }, + ], + }); + setSelectedGroup(null); + setIsEditing(false); + }; + + // 모달 열기 (신규) + const openCreateModal = () => { + resetForm(); + setModalOpen(true); + }; + + // 모달 열기 (수정) + const openEditModal = (group: HierarchyColumnGroup) => { + setSelectedGroup(group); + setIsEditing(true); + + // 매핑 데이터 변환 + const mappings = [1, 2, 3].map((depth) => { + const existing = group.mappings?.find((m) => m.depth === depth); + return { + depth, + levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"), + columnName: existing?.column_name || "", + placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`, + isRequired: existing?.is_required === "Y", + }; + }); + + setFormData({ + groupCode: group.group_code, + groupName: group.group_name, + description: group.description || "", + codeCategory: group.code_category, + tableName: group.table_name, + maxDepth: group.max_depth, + mappings, + }); + + // 컬럼 로드 + loadColumns(group.table_name); + setModalOpen(true); + }; + + // 삭제 확인 열기 + const openDeleteDialog = (group: HierarchyColumnGroup) => { + setSelectedGroup(group); + setDeleteDialogOpen(true); + }; + + // 저장 + const handleSave = async () => { + // 필수 필드 검증 + if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + // 최소 1개 컬럼 매핑 검증 + const validMappings = formData.mappings + .filter((m) => m.depth <= formData.maxDepth && m.columnName) + .map((m) => ({ + depth: m.depth, + levelLabel: m.levelLabel, + columnName: m.columnName, + placeholder: m.placeholder, + isRequired: m.isRequired, + })); + + if (validMappings.length === 0) { + toast.error("최소 하나의 컬럼 매핑이 필요합니다."); + return; + } + + try { + if (isEditing && selectedGroup) { + // 수정 + const response = await hierarchyColumnApi.update(selectedGroup.group_id, { + groupName: formData.groupName, + description: formData.description, + maxDepth: formData.maxDepth, + mappings: validMappings, + }); + + if (response.success) { + toast.success("계층구조 그룹이 수정되었습니다."); + setModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "수정 실패"); + } + } else { + // 생성 + const request: CreateHierarchyGroupRequest = { + groupCode: formData.groupCode, + groupName: formData.groupName, + description: formData.description, + codeCategory: formData.codeCategory, + tableName: formData.tableName, + maxDepth: formData.maxDepth, + mappings: validMappings, + }; + + const response = await hierarchyColumnApi.create(request); + + if (response.success) { + toast.success("계층구조 그룹이 생성되었습니다."); + setModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "생성 실패"); + } + } + } catch (error) { + console.error("저장 에러:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async () => { + if (!selectedGroup) return; + + try { + const response = await hierarchyColumnApi.delete(selectedGroup.group_id); + if (response.success) { + toast.success("계층구조 그룹이 삭제되었습니다."); + setDeleteDialogOpen(false); + loadGroups(); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error) { + console.error("삭제 에러:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + // 매핑 컬럼 변경 + const handleMappingChange = (depth: number, field: string, value: any) => { + setFormData((prev) => ({ + ...prev, + mappings: prev.mappings.map((m) => + m.depth === depth ? { ...m, [field]: value } : m + ), + })); + }; + + return ( +
+ {/* 헤더 */} +
+
+

계층구조 컬럼 그룹

+

+ 공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다. +

+
+
+ + +
+
+ + {/* 그룹 목록 */} + {loading ? ( +
+ + 로딩 중... +
+ ) : groups.length === 0 ? ( + + + +

계층구조 컬럼 그룹이 없습니다.

+ +
+
+ ) : ( +
+ {groups.map((group) => ( + + +
+
+ {group.group_name} + {group.group_code} +
+
+ + +
+
+
+ +
+ + {group.table_name} +
+
+ {group.code_category} + {group.max_depth}단계 +
+ {group.mappings && group.mappings.length > 0 && ( +
+ {group.mappings.map((mapping) => ( +
+ + {mapping.level_label} + + {mapping.column_name} +
+ ))} +
+ )} +
+
+ ))} +
+ )} + + {/* 생성/수정 모달 */} + + + + {isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"} + + 공통코드 계층구조를 테이블 컬럼에 매핑합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })} + placeholder="예: ITEM_CAT_HIERARCHY" + disabled={isEditing} + /> +
+
+ + setFormData({ ...formData, groupName: e.target.value })} + placeholder="예: 품목분류 계층" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="계층구조에 대한 설명" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {/* 컬럼 매핑 */} +
+ +

+ 각 계층 레벨에 저장할 컬럼을 선택합니다. +

+ + {formData.mappings + .filter((m) => m.depth <= formData.maxDepth) + .map((mapping) => ( +
+
+ + {mapping.depth}단계 + + handleMappingChange(mapping.depth, "levelLabel", e.target.value)} + className="h-8 text-xs" + placeholder="라벨" + /> +
+ + handleMappingChange(mapping.depth, "placeholder", e.target.value)} + className="h-8 text-xs" + placeholder="플레이스홀더" + /> +
+ handleMappingChange(mapping.depth, "isRequired", e.target.checked)} + className="h-4 w-4" + /> + 필수 +
+
+ ))} +
+
+ + + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 계층구조 그룹 삭제 + + "{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index b554dff1..0b5ff573 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -56,6 +56,7 @@ interface ColumnTypeInfo { referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 + hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 } interface SecondLevelMenu { @@ -292,11 +293,27 @@ export default function TableManagementPage() { }); // 컬럼 데이터에 기본값 설정 - const processedColumns = (data.columns || data).map((col: any) => ({ - ...col, - inputType: col.inputType || "text", // 기본값: text - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - })); + const processedColumns = (data.columns || data).map((col: any) => { + // detailSettings에서 hierarchyRole 추출 + let hierarchyRole: "large" | "medium" | "small" | undefined = undefined; + if (col.detailSettings && typeof col.detailSettings === "string") { + try { + const parsed = JSON.parse(col.detailSettings); + if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") { + hierarchyRole = parsed.hierarchyRole; + } + } catch { + // JSON 파싱 실패 시 무시 + } + } + + return { + ...col, + inputType: col.inputType || "text", // 기본값: text + categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 + hierarchyRole, // 계층구조 역할 + }; + }); if (page === 1) { setColumns(processedColumns); @@ -367,18 +384,40 @@ export default function TableManagementPage() { let referenceTable = col.referenceTable; let referenceColumn = col.referenceColumn; let displayColumn = col.displayColumn; + let hierarchyRole = col.hierarchyRole; if (settingType === "code") { if (value === "none") { newDetailSettings = ""; codeCategory = undefined; codeValue = undefined; + hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화 } else { - const codeOption = commonCodeOptions.find((option) => option.value === value); - newDetailSettings = codeOption ? `공통코드: ${codeOption.label}` : ""; + // 기존 hierarchyRole 유지하면서 JSON 형식으로 저장 + const existingHierarchyRole = hierarchyRole; + newDetailSettings = JSON.stringify({ + codeCategory: value, + hierarchyRole: existingHierarchyRole + }); codeCategory = value; codeValue = value; } + } else if (settingType === "hierarchy_role") { + // 계층구조 역할 변경 - JSON 형식으로 저장 + hierarchyRole = value === "none" ? undefined : (value as "large" | "medium" | "small"); + // detailSettings를 JSON으로 업데이트 + let existingSettings: Record = {}; + if (typeof col.detailSettings === "string" && col.detailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(col.detailSettings); + } catch { + existingSettings = {}; + } + } + newDetailSettings = JSON.stringify({ + ...existingSettings, + hierarchyRole: hierarchyRole, + }); } else if (settingType === "entity") { if (value === "none") { newDetailSettings = ""; @@ -415,6 +454,7 @@ export default function TableManagementPage() { referenceTable, referenceColumn, displayColumn, + hierarchyRole, }; } return col; @@ -487,6 +527,26 @@ export default function TableManagementPage() { console.log("🔧 Entity 설정 JSON 생성:", entitySettings); } + // 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함 + if (column.inputType === "code" && column.hierarchyRole) { + let existingSettings: Record = {}; + if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(finalDetailSettings); + } catch { + existingSettings = {}; + } + } + + const codeSettings = { + ...existingSettings, + hierarchyRole: column.hierarchyRole, + }; + + finalDetailSettings = JSON.stringify(codeSettings); + console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 @@ -1229,23 +1289,44 @@ export default function TableManagementPage() { {/* 입력 타입이 'code'인 경우 공통코드 선택 */} {column.inputType === "code" && ( - + <> + + {/* 계층구조 역할 선택 */} + {column.codeCategory && column.codeCategory !== "none" && ( + + )} + )} {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} {column.inputType === "category" && ( diff --git a/frontend/components/admin/CodeDetailPanel.tsx b/frontend/components/admin/CodeDetailPanel.tsx index 62f33cd2..3110a5ee 100644 --- a/frontend/components/admin/CodeDetailPanel.tsx +++ b/frontend/components/admin/CodeDetailPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -45,15 +45,124 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { const reorderCodesMutation = useReorderCodes(); // 드래그앤드롭을 위해 필터링된 코드 목록 사용 - const { filteredItems: filteredCodes } = useSearchAndFilter(codes, { + const { filteredItems: filteredCodesRaw } = useSearchAndFilter(codes, { searchFields: ["code_name", "code_value"], }); + // 계층 구조로 정렬 (부모 → 자식 순서) + const filteredCodes = useMemo(() => { + if (!filteredCodesRaw || filteredCodesRaw.length === 0) return []; + + // 코드를 계층 순서로 정렬하는 함수 + const sortHierarchically = (codes: CodeInfo[]): CodeInfo[] => { + const result: CodeInfo[] = []; + const codeMap = new Map(); + const childrenMap = new Map(); + + // 코드 맵 생성 + codes.forEach((code) => { + const codeValue = code.codeValue || code.code_value || ""; + const parentValue = code.parentCodeValue || code.parent_code_value; + codeMap.set(codeValue, code); + + if (parentValue) { + if (!childrenMap.has(parentValue)) { + childrenMap.set(parentValue, []); + } + childrenMap.get(parentValue)!.push(code); + } + }); + + // 재귀적으로 트리 구조 순회 + const traverse = (parentValue: string | null, depth: number) => { + const children = parentValue + ? childrenMap.get(parentValue) || [] + : codes.filter((c) => !c.parentCodeValue && !c.parent_code_value); + + // 정렬 순서로 정렬 + children + .sort((a, b) => (a.sortOrder || a.sort_order || 0) - (b.sortOrder || b.sort_order || 0)) + .forEach((code) => { + result.push(code); + const codeValue = code.codeValue || code.code_value || ""; + traverse(codeValue, depth + 1); + }); + }; + + traverse(null, 1); + + // 트리에 포함되지 않은 코드들도 추가 (orphan 코드) + codes.forEach((code) => { + if (!result.includes(code)) { + result.push(code); + } + }); + + return result; + }; + + return sortHierarchically(filteredCodesRaw); + }, [filteredCodesRaw]); + // 모달 상태 const [showFormModal, setShowFormModal] = useState(false); const [editingCode, setEditingCode] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); const [deletingCode, setDeletingCode] = useState(null); + const [defaultParentCode, setDefaultParentCode] = useState(undefined); + + // 트리 접기/펼치기 상태 (코드값 Set) + const [collapsedCodes, setCollapsedCodes] = useState>(new Set()); + + // 자식 정보 계산 + const childrenMap = useMemo(() => { + const map = new Map(); + codes.forEach((code) => { + const parentValue = code.parentCodeValue || code.parent_code_value; + if (parentValue) { + if (!map.has(parentValue)) { + map.set(parentValue, []); + } + map.get(parentValue)!.push(code); + } + }); + return map; + }, [codes]); + + // 접기/펼치기 토글 + const toggleExpand = (codeValue: string) => { + setCollapsedCodes((prev) => { + const newSet = new Set(prev); + if (newSet.has(codeValue)) { + newSet.delete(codeValue); + } else { + newSet.add(codeValue); + } + return newSet; + }); + }; + + // 특정 코드가 표시되어야 하는지 확인 (부모가 접혀있으면 숨김) + const isCodeVisible = (code: CodeInfo): boolean => { + const parentValue = code.parentCodeValue || code.parent_code_value; + if (!parentValue) return true; // 최상위 코드는 항상 표시 + + // 부모가 접혀있으면 숨김 + if (collapsedCodes.has(parentValue)) return false; + + // 부모의 부모도 확인 (재귀적으로) + const parentCode = codes.find((c) => (c.codeValue || c.code_value) === parentValue); + if (parentCode) { + return isCodeVisible(parentCode); + } + + return true; + }; + + // 표시할 코드 목록 (접힌 상태 반영) + const visibleCodes = useMemo(() => { + return filteredCodes.filter(isCodeVisible); + }, [filteredCodes, collapsedCodes, codes]); // 드래그 앤 드롭 훅 사용 const dragAndDrop = useDragAndDrop({ @@ -73,12 +182,21 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { // 새 코드 생성 const handleNewCode = () => { setEditingCode(null); + setDefaultParentCode(undefined); setShowFormModal(true); }; // 코드 수정 const handleEditCode = (code: CodeInfo) => { setEditingCode(code); + setDefaultParentCode(undefined); + setShowFormModal(true); + }; + + // 하위 코드 추가 + const handleAddChild = (parentCode: CodeInfo) => { + setEditingCode(null); + setDefaultParentCode(parentCode.codeValue || parentCode.code_value || ""); setShowFormModal(true); }; @@ -110,7 +228,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { if (!categoryCode) { return (
-

카테고리를 선택하세요

+

카테고리를 선택하세요

); } @@ -119,7 +237,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { return (
-

코드를 불러오는 중 오류가 발생했습니다.

+

코드를 불러오는 중 오류가 발생했습니다.

@@ -135,7 +253,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { {/* 검색 + 버튼 */}
- + setShowActiveOnly(e.target.checked)} - className="h-4 w-4 rounded border-input" + className="border-input h-4 w-4 rounded" /> -
@@ -170,9 +288,9 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
- ) : filteredCodes.length === 0 ? ( + ) : visibleCodes.length === 0 ? (
-

+

{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}

@@ -180,23 +298,35 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { <> code.codeValue || code.code_value)} + items={visibleCodes.map((code) => code.codeValue || code.code_value)} strategy={verticalListSortingStrategy} > - {filteredCodes.map((code, index) => ( - handleEditCode(code)} - onDelete={() => handleDeleteCode(code)} - /> - ))} + {visibleCodes.map((code, index) => { + const codeValue = code.codeValue || code.code_value || ""; + const children = childrenMap.get(codeValue) || []; + const hasChildren = children.length > 0; + const isExpanded = !collapsedCodes.has(codeValue); + + return ( + handleEditCode(code)} + onDelete={() => handleDeleteCode(code)} + onAddChild={() => handleAddChild(code)} + hasChildren={hasChildren} + childCount={children.length} + isExpanded={isExpanded} + onToggleExpand={() => toggleExpand(codeValue)} + /> + ); + })} {dragAndDrop.activeItem ? ( -
+
{(() => { const activeCode = dragAndDrop.activeItem; if (!activeCode) return null; @@ -204,24 +334,20 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
-

- {activeCode.codeName || activeCode.code_name} -

+

{activeCode.codeName || activeCode.code_name}

{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
-

+

{activeCode.codeValue || activeCode.code_value}

{activeCode.description && ( -

{activeCode.description}

+

{activeCode.description}

)}
@@ -236,13 +362,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { {isFetchingNextPage && (
- 코드를 더 불러오는 중... + 코드를 더 불러오는 중...
)} {/* 모든 코드 로드 완료 메시지 */} {!hasNextPage && codes.length > 0 && ( -
모든 코드를 불러왔습니다.
+
모든 코드를 불러왔습니다.
)} )} @@ -255,10 +381,12 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { onClose={() => { setShowFormModal(false); setEditingCode(null); + setDefaultParentCode(undefined); }} categoryCode={categoryCode} editingCode={editingCode} codes={codes} + defaultParentCode={defaultParentCode} /> )} diff --git a/frontend/components/admin/CodeFormModal.tsx b/frontend/components/admin/CodeFormModal.tsx index 977e9e84..b5a8847b 100644 --- a/frontend/components/admin/CodeFormModal.tsx +++ b/frontend/components/admin/CodeFormModal.tsx @@ -24,6 +24,7 @@ interface CodeFormModalProps { categoryCode: string; editingCode?: CodeInfo | null; codes: CodeInfo[]; + defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드 } // 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수 @@ -33,28 +34,32 @@ const getErrorMessage = (error: FieldError | undefined): string => { return error.message || ""; }; -export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, codes }: CodeFormModalProps) { +// 코드값 자동 생성 함수 (UUID 기반 짧은 코드) +const generateCodeValue = (): string => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `${timestamp}${random}`; +}; + +export function CodeFormModal({ + isOpen, + onClose, + categoryCode, + editingCode, + codes, + defaultParentCode, +}: CodeFormModalProps) { const createCodeMutation = useCreateCode(); const updateCodeMutation = useUpdateCode(); const isEditing = !!editingCode; - // 검증 상태 관리 + // 검증 상태 관리 (코드명만 중복 검사) const [validationStates, setValidationStates] = useState({ - codeValue: { enabled: false, value: "" }, codeName: { enabled: false, value: "" }, - codeNameEng: { enabled: false, value: "" }, }); - // 중복 검사 훅들 - const codeValueCheck = useCheckCodeDuplicate( - categoryCode, - "codeValue", - validationStates.codeValue.value, - isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined, - validationStates.codeValue.enabled, - ); - + // 코드명 중복 검사 const codeNameCheck = useCheckCodeDuplicate( categoryCode, "codeName", @@ -63,22 +68,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code validationStates.codeName.enabled, ); - const codeNameEngCheck = useCheckCodeDuplicate( - categoryCode, - "codeNameEng", - validationStates.codeNameEng.value, - isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined, - validationStates.codeNameEng.enabled, - ); - // 중복 검사 결과 확인 - const hasDuplicateErrors = - (codeValueCheck.data?.isDuplicate && validationStates.codeValue.enabled) || - (codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled) || - (codeNameEngCheck.data?.isDuplicate && validationStates.codeNameEng.enabled); + const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled; // 중복 검사 로딩 중인지 확인 - const isDuplicateChecking = codeValueCheck.isLoading || codeNameCheck.isLoading || codeNameEngCheck.isLoading; + const isDuplicateChecking = codeNameCheck.isLoading; // 폼 스키마 선택 (생성/수정에 따라) const schema = isEditing ? updateCodeSchema : createCodeSchema; @@ -92,6 +86,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code codeNameEng: "", description: "", sortOrder: 1, + parentCodeValue: "" as string | undefined, ...(isEditing && { isActive: "Y" as const }), }, }); @@ -101,30 +96,40 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code if (isOpen) { if (isEditing && editingCode) { // 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정) + const parentValue = editingCode.parentCodeValue || editingCode.parent_code_value || ""; + form.reset({ codeName: editingCode.codeName || editingCode.code_name, codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "", description: editingCode.description || "", sortOrder: editingCode.sortOrder || editingCode.sort_order, - isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", // 타입 캐스팅 + isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", + parentCodeValue: parentValue, }); // codeValue는 별도로 설정 (표시용) form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value); } else { // 새 코드 모드: 자동 순서 계산 - const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0; + const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order || 0)) : 0; + + // 기본 부모 코드가 있으면 설정 (하위 코드 추가 시) + const parentValue = defaultParentCode || ""; + + // 코드값 자동 생성 + const autoCodeValue = generateCodeValue(); form.reset({ - codeValue: "", + codeValue: autoCodeValue, codeName: "", codeNameEng: "", description: "", sortOrder: maxSortOrder + 1, + parentCodeValue: parentValue, }); } } - }, [isOpen, isEditing, editingCode, codes]); + }, [isOpen, isEditing, editingCode, codes, defaultParentCode]); const handleSubmit = form.handleSubmit(async (data) => { try { @@ -132,7 +137,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code // 수정 await updateCodeMutation.mutateAsync({ categoryCode, - codeValue: editingCode.codeValue || editingCode.code_value, + codeValue: editingCode.codeValue || editingCode.code_value || "", data: data as UpdateCodeData, }); } else { @@ -156,50 +161,38 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code - {isEditing ? "코드 수정" : "새 코드"} + + {isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"} +
- {/* 코드값 */} -
- - { - const value = e.target.value.trim(); - if (value && !isEditing) { - setValidationStates((prev) => ({ - ...prev, - codeValue: { enabled: true, value }, - })); - } - }} - /> - {(form.formState.errors as any)?.codeValue && ( -

{getErrorMessage((form.formState.errors as any)?.codeValue)}

- )} - {!isEditing && !(form.formState.errors as any)?.codeValue && ( - - )} -
+ {/* 코드값 (자동 생성, 수정 시에만 표시) */} + {isEditing && ( +
+ +
+ {form.watch("codeValue")} +
+

코드값은 변경할 수 없습니다

+
+ )} {/* 코드명 */}
- + { const value = e.target.value.trim(); if (value) { @@ -211,7 +204,9 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code }} /> {form.formState.errors.codeName && ( -

{getErrorMessage(form.formState.errors.codeName)}

+

+ {getErrorMessage(form.formState.errors.codeName)} +

)} {!form.formState.errors.codeName && ( - {/* 영문명 */} + {/* 영문명 (선택) */}
- + { - const value = e.target.value.trim(); - if (value) { - setValidationStates((prev) => ({ - ...prev, - codeNameEng: { enabled: true, value }, - })); - } - }} + placeholder="코드 영문명을 입력하세요 (선택사항)" + className="h-8 text-xs sm:h-10 sm:text-sm" /> - {form.formState.errors.codeNameEng && ( -

{getErrorMessage(form.formState.errors.codeNameEng)}

- )} - {!form.formState.errors.codeNameEng && ( - - )}
- {/* 설명 */} + {/* 설명 (선택) */}
- +