From e2cc09b2d6e0ad6ece634d0ec21d5a58e097dc46 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 16:21:18 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=9C=84=EC=A0=AF=20=ED=99=94=EB=A9=B4=EB=B3=84=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BD=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 필터 설정을 화면별로 독립적으로 저장하도록 개선 (screenId 포함) - FilterPanel, TableSearchWidget, TableListComponent에 화면 ID 기반 localStorage 키 적용 - 동적 모드(사용자 설정)와 고정 모드(디자이너 설정) 2가지 필터 방식 추가 - 고정 모드에서 컬럼 드롭다운 선택 기능 구현 - 컬럼 선택 시 라벨 및 필터 타입 자동 설정 - ConfigPanel 표시 문제 해결 (type='component' 지원) - UnifiedPropertiesPanel에서 독립 컴포넌트 ConfigPanel 조회 개선 주요 변경: - 같은 테이블을 사용하는 다른 화면에서 필터 설정이 독립적으로 관리됨 - 고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시 - 테이블 정보가 있으면 컬럼을 드롭다운으로 선택 가능 - inputType에 따라 filterType 자동 추론 (number, date, select, text) --- .../screen/panels/UnifiedPropertiesPanel.tsx | 19 +- .../screen/table-options/FilterPanel.tsx | 20 +- .../table-list/TableListComponent.tsx | 19 +- .../table-search-widget/TableSearchWidget.tsx | 155 ++++++---- .../TableSearchWidgetConfigPanel.tsx | 285 +++++++++++++++++- .../TableSearchWidgetRenderer.tsx | 4 +- 6 files changed, 415 insertions(+), 87 deletions(-) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6c77b4f1..49f31679 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -286,7 +286,8 @@ export const UnifiedPropertiesPanel: React.FC = ({ const componentId = selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentConfig?.type || - selectedComponent.componentConfig?.id; + selectedComponent.componentConfig?.id || + (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) if (componentId) { const definition = ComponentRegistry.getComponent(componentId); @@ -318,7 +319,11 @@ export const UnifiedPropertiesPanel: React.FC = ({

{definition.name} 설정

- + ); }; @@ -994,6 +999,16 @@ export const UnifiedPropertiesPanel: React.FC = ({ ); } + // 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인 + const definition = ComponentRegistry.getComponent(componentId); + if (definition?.configPanel) { + // 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출 + const configPanelContent = renderComponentConfigPanel(); + if (configPanelContent) { + return configPanelContent; + } + } + // 현재 웹타입의 기본 입력 타입 추출 const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; diff --git a/frontend/components/screen/table-options/FilterPanel.tsx b/frontend/components/screen/table-options/FilterPanel.tsx index 4688bb18..69395942 100644 --- a/frontend/components/screen/table-options/FilterPanel.tsx +++ b/frontend/components/screen/table-options/FilterPanel.tsx @@ -26,6 +26,7 @@ interface Props { isOpen: boolean; onClose: () => void; onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백 + screenId?: number; // 화면 ID 추가 } // 필터 타입별 연산자 @@ -69,7 +70,7 @@ interface ColumnFilterConfig { selectOptions?: Array<{ label: string; value: string }>; } -export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied }) => { +export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied, screenId }) => { const { getTable, selectedTableId } = useTableOptions(); const table = selectedTableId ? getTable(selectedTableId) : undefined; @@ -79,7 +80,10 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // localStorage에서 저장된 필터 설정 불러오기 useEffect(() => { if (table?.columns && table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + // 화면별로 독립적인 필터 설정 저장 + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; const savedFilters = localStorage.getItem(storageKey); let filters: ColumnFilterConfig[]; @@ -192,9 +196,11 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied width: cf.width || 200, // 너비 포함 (기본 200px) })); - // localStorage에 저장 + // localStorage에 저장 (화면별로 독립적) if (table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; localStorage.setItem(storageKey, JSON.stringify(columnFilters)); } @@ -216,9 +222,11 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied setColumnFilters(clearedFilters); setSelectAll(false); - // localStorage에서 제거 + // localStorage에서 제거 (화면별로 독립적) if (table?.tableName) { - const storageKey = `table_filters_${table.tableName}`; + const storageKey = screenId + ? `table_filters_${table.tableName}_screen_${screenId}` + : `table_filters_${table.tableName}`; localStorage.removeItem(storageKey); } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 74d81211..24b80975 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -148,7 +148,7 @@ export interface TableListComponentProps { tableName?: string; onRefresh?: () => void; onClose?: () => void; - screenId?: string; + screenId?: number | string; // 화면 ID (필터 설정 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용) onSelectedRowsChange?: ( selectedRows: any[], @@ -183,6 +183,7 @@ export const TableListComponent: React.FC = ({ refreshKey, tableName, userId, + screenId, // 화면 ID 추출 }) => { // ======================================== // 설정 및 스타일 @@ -1535,17 +1536,21 @@ export const TableListComponent: React.FC = ({ // useEffect 훅 // ======================================== - // 필터 설정 localStorage 키 생성 + // 필터 설정 localStorage 키 생성 (화면별로 독립적) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return `tableList_filterSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + return screenId + ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` + : `tableList_filterSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, screenId]); - // 그룹 설정 localStorage 키 생성 + // 그룹 설정 localStorage 키 생성 (화면별로 독립적) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return `tableList_groupSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + return screenId + ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` + : `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, screenId]); // 저장된 필터 설정 불러오기 useEffect(() => { diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 01906c21..0416c4b3 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -12,6 +12,14 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { TableFilter } from "@/types/table-options"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +interface PresetFilter { + id: string; + columnName: string; + columnLabel: string; + filterType: "text" | "number" | "date" | "select"; + width?: number; +} + interface TableSearchWidgetProps { component: { id: string; @@ -25,6 +33,8 @@ interface TableSearchWidgetProps { componentConfig?: { autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부 showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 + filterMode?: "dynamic" | "preset"; // 필터 모드 + presetFilters?: PresetFilter[]; // 고정 필터 목록 }; }; screenId?: number; // 화면 ID @@ -63,6 +73,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true; const showTableSelector = component.componentConfig?.showTableSelector ?? true; + const filterMode = component.componentConfig?.filterMode ?? "dynamic"; + const presetFilters = component.componentConfig?.presetFilters ?? []; // Map을 배열로 변환 const tableList = Array.from(registeredTables.values()); @@ -77,41 +89,58 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table } }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); - // 현재 테이블의 저장된 필터 불러오기 + // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) useEffect(() => { - if (currentTable?.tableName) { - const storageKey = `table_filters_${currentTable.tableName}`; - const savedFilters = localStorage.getItem(storageKey); + if (!currentTable?.tableName) return; - if (savedFilters) { - try { - const parsed = JSON.parse(savedFilters) as Array<{ - columnName: string; - columnLabel: string; - inputType: string; - enabled: boolean; - filterType: "text" | "number" | "date" | "select"; - width?: number; - }>; + // 고정 모드: presetFilters를 activeFilters로 설정 + if (filterMode === "preset") { + const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({ + columnName: f.columnName, + operator: "contains", + value: "", + filterType: f.filterType, + width: f.width || 200, + })); + setActiveFilters(activeFiltersList); + return; + } - // enabled된 필터들만 activeFilters로 설정 - const activeFiltersList: TableFilter[] = parsed - .filter((f) => f.enabled) - .map((f) => ({ - columnName: f.columnName, - operator: "contains", - value: "", - filterType: f.filterType, - width: f.width || 200, // 저장된 너비 포함 - })); + // 동적 모드: 화면별로 독립적인 필터 설정 불러오기 + const storageKey = screenId + ? `table_filters_${currentTable.tableName}_screen_${screenId}` + : `table_filters_${currentTable.tableName}`; + const savedFilters = localStorage.getItem(storageKey); - setActiveFilters(activeFiltersList); - } catch (error) { - console.error("저장된 필터 불러오기 실패:", error); - } + if (savedFilters) { + try { + const parsed = JSON.parse(savedFilters) as Array<{ + columnName: string; + columnLabel: string; + inputType: string; + enabled: boolean; + filterType: "text" | "number" | "date" | "select"; + width?: number; + }>; + + // enabled된 필터들만 activeFilters로 설정 + const activeFiltersList: TableFilter[] = parsed + .filter((f) => f.enabled) + .map((f) => ({ + columnName: f.columnName, + operator: "contains", + value: "", + filterType: f.filterType, + width: f.width || 200, // 저장된 너비 포함 + })); + + setActiveFilters(activeFiltersList); + } catch (error) { + console.error("저장된 필터 불러오기 실패:", error); } } - }, [currentTable?.tableName]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]); // select 옵션 초기 로드 (한 번만 실행, 이후 유지) useEffect(() => { @@ -362,7 +391,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table {/* 필터가 없을 때는 빈 공간 */} {activeFilters.length === 0 &&
} - {/* 오른쪽: 데이터 건수 + 설정 버튼들 */} + {/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
{/* 데이터 건수 표시 */} {currentTable?.dataCount !== undefined && ( @@ -371,38 +400,43 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
)} - + {/* 동적 모드일 때만 설정 버튼들 표시 */} + {filterMode === "dynamic" && ( + <> + - + - + + + )}
{/* 패널들 */} @@ -411,6 +445,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table isOpen={filterOpen} onClose={() => setFilterOpen(false)} onFiltersApplied={(filters) => setActiveFilters(filters)} + screenId={screenId} /> setGroupingOpen(false)} /> diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx index 646fd3c4..8c4ab6a1 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx @@ -3,27 +3,126 @@ import React, { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, X } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; interface TableSearchWidgetConfigPanelProps { - component: any; - onUpdateProperty: (property: string, value: any) => void; + component?: any; // 레거시 지원 + config?: any; // 새 인터페이스 + onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원 + onChange?: (newConfig: any) => void; // 새 인터페이스 + tables?: any[]; // 화면의 테이블 정보 +} + +interface PresetFilter { + id: string; + columnName: string; + columnLabel: string; + filterType: "text" | "number" | "date" | "select"; + width?: number; } export function TableSearchWidgetConfigPanel({ component, + config, onUpdateProperty, + onChange, + tables = [], }: TableSearchWidgetConfigPanelProps) { + // 레거시와 새 인터페이스 모두 지원 + const currentConfig = config || component?.componentConfig || {}; + const updateConfig = onChange || ((key: string, value: any) => { + if (onUpdateProperty) { + onUpdateProperty(`componentConfig.${key}`, value); + } + }); + + // 첫 번째 테이블의 컬럼 목록 가져오기 + const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : []; + + // inputType에서 filterType 추출 헬퍼 함수 + const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => { + if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) { + return "number"; + } + if (inputType.includes("date") || inputType.includes("time")) { + return "date"; + } + if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) { + return "select"; + } + return "text"; + }; + const [localAutoSelect, setLocalAutoSelect] = useState( - component.componentConfig?.autoSelectFirstTable ?? true + currentConfig.autoSelectFirstTable ?? true ); const [localShowSelector, setLocalShowSelector] = useState( - component.componentConfig?.showTableSelector ?? true + currentConfig.showTableSelector ?? true + ); + const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">( + currentConfig.filterMode ?? "dynamic" + ); + const [localPresetFilters, setLocalPresetFilters] = useState( + currentConfig.presetFilters ?? [] ); useEffect(() => { - setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true); - setLocalShowSelector(component.componentConfig?.showTableSelector ?? true); - }, [component.componentConfig]); + setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true); + setLocalShowSelector(currentConfig.showTableSelector ?? true); + setLocalFilterMode(currentConfig.filterMode ?? "dynamic"); + setLocalPresetFilters(currentConfig.presetFilters ?? []); + }, [currentConfig]); + + // 설정 업데이트 헬퍼 + const handleUpdate = (key: string, value: any) => { + if (onChange) { + // 새 인터페이스: 전체 config 업데이트 + onChange({ ...currentConfig, [key]: value }); + } else if (onUpdateProperty) { + // 레거시: 개별 속성 업데이트 + onUpdateProperty(`componentConfig.${key}`, value); + } + }; + + // 필터 추가 + const addFilter = () => { + const newFilter: PresetFilter = { + id: `filter_${Date.now()}`, + columnName: "", + columnLabel: "", + filterType: "text", + width: 200, + }; + const updatedFilters = [...localPresetFilters, newFilter]; + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; + + // 필터 삭제 + const removeFilter = (id: string) => { + const updatedFilters = localPresetFilters.filter((f) => f.id !== id); + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; + + // 필터 업데이트 + const updateFilter = (id: string, field: keyof PresetFilter, value: any) => { + const updatedFilters = localPresetFilters.map((f) => + f.id === id ? { ...f, [field]: value } : f + ); + setLocalPresetFilters(updatedFilters); + handleUpdate("presetFilters", updatedFilters); + }; return (
@@ -41,7 +140,7 @@ export function TableSearchWidgetConfigPanel({ checked={localAutoSelect} onCheckedChange={(checked) => { setLocalAutoSelect(checked as boolean); - onUpdateProperty("componentConfig.autoSelectFirstTable", checked); + handleUpdate("autoSelectFirstTable", checked); }} />
+ {/* 필터 모드 선택 */} +
+ + { + setLocalFilterMode(value); + handleUpdate("filterMode", value); + }} + > +
+ + +
+
+ + +
+
+
+ + {/* 고정 모드일 때만 필터 설정 UI 표시 */} + {localFilterMode === "preset" && ( +
+
+ + +
+ + {localPresetFilters.length === 0 ? ( +
+ 필터가 없습니다. 필터 추가 버튼을 클릭하세요. +
+ ) : ( +
+ {localPresetFilters.map((filter) => ( +
+
+ + +
+ + {/* 컬럼 선택 */} +
+ + {availableColumns.length > 0 ? ( + + ) : ( + updateFilter(filter.id, "columnName", e.target.value)} + placeholder="예: customer_name" + className="h-7 text-xs" + /> + )} + {filter.columnLabel && ( +

+ 표시명: {filter.columnLabel} +

+ )} +
+ + {/* 필터 타입 */} +
+ + +
+ + {/* 너비 */} +
+ + updateFilter(filter.id, "width", parseInt(e.target.value))} + placeholder="200" + className="h-7 text-xs" + min={100} + max={500} + /> +
+
+ ))} +
+ )} +
+ )} +

참고사항:

  • 테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다
  • 여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다
  • -
  • 선택한 테이블의 컬럼 정보가 자동으로 로드됩니다
  • + {localFilterMode === "dynamic" ? ( +
  • 사용자가 필터 설정 버튼을 클릭하여 원하는 필터를 선택합니다
  • + ) : ( +
  • 고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시됩니다
  • + )}
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx index 6fe47cc7..21e589ef 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetRenderer.tsx @@ -2,8 +2,8 @@ import React from "react"; import { TableSearchWidget } from "./TableSearchWidget"; export class TableSearchWidgetRenderer { - static render(component: any) { - return ; + static render(component: any, props?: any) { + return ; } } From d7db8cb07a45dde77f112a3e9c3e513d0c701669 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:07:12 +0900 Subject: [PATCH 02/30] =?UTF-8?q?fix:=20TableListConfigPanel=EC=97=90=20sc?= =?UTF-8?q?reenTableName=20=EC=A0=84=EB=8B=AC=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderComponentConfigPanel에서 ConfigPanelComponent 호출 시 screenTableName과 tableColumns 전달 추가 - 이전 커밋(e2cc09b2)에서 renderComponentConfigPanel 로직 추가로 인한 회귀 버그 수정 - table-list 컴포넌트 설정 패널에서 컬럼 추가 기능 정상 작동 --- frontend/components/screen/panels/UnifiedPropertiesPanel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 49f31679..7ec802f3 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -323,6 +323,8 @@ export const UnifiedPropertiesPanel: React.FC = ({ config={config} onChange={handleConfigChange} tables={tables} // 테이블 정보 전달 + screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달 + tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달 /> ); From 6f3bcd7b46e7b676676453b781c2148ef2290ec3 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:18:30 +0900 Subject: [PATCH 03/30] =?UTF-8?q?fix:=20table-list=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EB=93=B1=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UnifiedPropertiesPanel의 handleConfigChange에서 config 병합 로직 추가 - 기존 config와 새 config를 merge하여 checkbox 등 다른 설정이 사라지지 않도록 수정 - 이전에는 부분 업데이트된 config만 전달되어 다른 속성들이 손실되는 문제 해결 --- frontend/components/screen/panels/UnifiedPropertiesPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 7ec802f3..1d4829bb 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -279,7 +279,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ }; const handleConfigChange = (newConfig: any) => { - onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig); + // 기존 config와 병합하여 다른 속성 유지 + const currentConfig = selectedComponent.componentConfig?.config || {}; + const mergedConfig = { ...currentConfig, ...newConfig }; + onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig); }; // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 From c51cd7bc875829e8b21382ed19a5d67ae6ba07a8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:31:42 +0900 Subject: [PATCH 04/30] =?UTF-8?q?fix:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20config=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EB=B0=8F=20props=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableListConfigPanel: handleNestedChange에서 전체 config 병합 로직 추가 - TableListComponent: checkbox.enabled 및 position 기본값 처리 (undefined시 기본값 사용) - SelectedItemsDetailInputConfigPanel: handleChange에서 전체 config 병합 로직 추가 - SelectedItemsDetailInputConfigPanel: 원본 데이터 테이블 선택 disabled 조건 제거 - UnifiedPropertiesPanel: allTables 로드 및 ConfigPanel에 전달 추가 문제: 1. table-list 컴포넌트 체크박스 설정 변경 시 다른 설정 초기화 2. selected-items-detail-input 설정 변경 시 컴포넌트 이름 등 다른 속성 손실 3. 원본 데이터 테이블 선택이 비활성화되어 있음 해결: - 모든 설정 패널에서 부분 업데이트 시 기존 config와 병합하도록 수정 - checkbox 관련 기본값 처리로 기존 컴포넌트 호환성 보장 - allTables를 별도로 로드하여 전체 테이블 목록 제공 --- .../screen/panels/UnifiedPropertiesPanel.tsx | 20 ++++++++ .../SelectedItemsDetailInputConfigPanel.tsx | 4 +- .../table-list/TableListComponent.tsx | 5 +- .../table-list/TableListConfigPanel.tsx | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 1d4829bb..61051439 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -119,6 +119,25 @@ export const UnifiedPropertiesPanel: React.FC = ({ const [localHeight, setLocalHeight] = useState(""); const [localWidth, setLocalWidth] = useState(""); + // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) + const [allTables, setAllTables] = useState>([]); + + // 🆕 전체 테이블 목록 로드 + useEffect(() => { + const loadAllTables = async () => { + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables(response.data); + } + } catch (error) { + console.error("전체 테이블 목록 로드 실패:", error); + } + }; + loadAllTables(); + }, []); + // 새로운 컴포넌트 시스템의 webType 동기화 useEffect(() => { if (selectedComponent?.type === "component") { @@ -326,6 +345,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ config={config} onChange={handleConfigChange} tables={tables} // 테이블 정보 전달 + allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용) screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달 tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달 /> diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 408934e2..fa4813c2 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -180,7 +180,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { - onChange({ [key]: value }); + // 🔧 기존 config와 병합하여 다른 속성 유지 + onChange({ ...config, [key]: value }); }; const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => { @@ -403,7 +404,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {selectedSourceTableLabel} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 24b80975..4a6aec26 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1228,8 +1228,9 @@ export const TableListComponent: React.FC = ({ } // 체크박스 컬럼 (나중에 위치 결정) + // 기본값: enabled가 undefined면 true로 처리 let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled) { + if (tableConfig.checkbox?.enabled ?? true) { checkboxCol = { columnName: "__checkbox__", displayName: "", @@ -1258,7 +1259,7 @@ export const TableListComponent: React.FC = ({ // 체크박스를 맨 앞 또는 맨 뒤에 추가 if (checkboxCol) { - if (tableConfig.checkbox.position === "right") { + if (tableConfig.checkbox?.position === "right") { cols = [...cols, checkboxCol]; } else { cols = [checkboxCol, ...cols]; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 6d76cfc8..c5ed9aaa 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -269,7 +269,9 @@ export const TableListConfigPanel: React.FC = ({ // }); const parentValue = config[parentKey] as any; + // 전체 config와 병합하여 다른 속성 유지 const newConfig = { + ...config, [parentKey]: { ...parentValue, [childKey]: value, @@ -754,6 +756,52 @@ export const TableListConfigPanel: React.FC = ({ + {/* 체크박스 설정 */} +
+
+

체크박스 설정

+
+
+
+
+ handleNestedChange("checkbox", "enabled", checked)} + /> + +
+ + {config.checkbox?.enabled && ( + <> +
+ handleNestedChange("checkbox", "selectAll", checked)} + /> + +
+ +
+ + +
+ + )} +
+
+ {/* 가로 스크롤 및 컬럼 고정 */}
From 6e92d1855a10b2bfbd28c8ce863b5fad3f922f01 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:37:51 +0900 Subject: [PATCH 05/30] =?UTF-8?q?fix:=20SelectedItemsDetailInput=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 테이블(sourceTable) 변경 시 컬럼 자동 로드 - 대상 테이블(targetTable) 변경 시 컬럼 자동 로드 - props로 받은 컬럼은 백업으로 사용하고, 내부에서 로드한 컬럼 우선 사용 - tableManagementApi.getColumnList() 사용하여 동적 로드 이제 원본/대상 테이블 선택 시 해당 테이블의 컬럼 목록이 자동으로 나타남 --- .../SelectedItemsDetailInputConfigPanel.tsx | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index fa4813c2..b08fbc64 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -73,6 +73,70 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>>({}); + // 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드) + const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // 🆕 원본 테이블 컬럼 로드 + useEffect(() => { + if (!config.sourceTable) { + setLoadedSourceTableColumns([]); + return; + } + + const loadColumns = async () => { + try { + console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(config.sourceTable); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setLoadedSourceTableColumns(columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + }))); + console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length); + } + } catch (error) { + console.error("❌ 원본 테이블 컬럼 로드 오류:", error); + } + }; + + loadColumns(); + }, [config.sourceTable]); + + // 🆕 대상 테이블 컬럼 로드 + useEffect(() => { + if (!config.targetTable) { + setLoadedTargetTableColumns([]); + return; + } + + const loadColumns = async () => { + try { + console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(config.targetTable); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setLoadedTargetTableColumns(columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + }))); + console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length); + } + } catch (error) { + console.error("❌ 대상 테이블 컬럼 로드 오류:", error); + } + }; + + loadColumns(); + }, [config.targetTable]); + // 🆕 소스 테이블 선택 시 컬럼 로드 const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => { try { @@ -262,15 +326,19 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + // 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업 + const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns; const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); - return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName)); - }, [sourceTableColumns, displayColumns, localFields]); + return columns.filter((col) => !usedColumns.has(col.columnName)); + }, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]); // 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록 const availableTargetColumns = useMemo(() => { + // 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업 + const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns; const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); - return targetTableColumns.filter((col) => !usedColumns.has(col.columnName)); - }, [targetTableColumns, displayColumns, localFields]); + return columns.filter((col) => !usedColumns.has(col.columnName)); + }, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]); // 🆕 원본 테이블 필터링 const filteredSourceTables = useMemo(() => { From 86eb9f042574300fd4e2932ee278af8183a61a8e Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:44:33 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EC=B1=84?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=BB=AC=EB=9F=BC=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추가 입력 필드에서 자동 채우기 테이블을 드롭다운으로 선택 가능 - 텍스트 입력 대신 allTables에서 선택하는 방식으로 개선 - 테이블 선택 시 해당 테이블의 컬럼을 자동으로 로드 - autoFillTableColumns 상태로 필드별 테이블 컬럼 관리 - 선택한 테이블에 따라 컬럼 드롭다운이 동적으로 변경됨 사용자 경험 개선: - 테이블명을 직접 입력하는 대신 목록에서 선택 - 선택한 테이블의 컬럼만 표시되어 혼란 방지 - 원본 테이블(기본) 또는 다른 테이블 선택 가능 --- .../SelectedItemsDetailInputConfigPanel.tsx | 165 ++++++++++++++---- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index b08fbc64..0a05ed0c 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -73,6 +73,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>>({}); + // 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태 + const [autoFillTableColumns, setAutoFillTableColumns] = useState>>({}); + // 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드) const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]); const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); @@ -137,6 +140,38 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + if (!tableName) { + setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] })); + return; + } + + try { + console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName); + + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(tableName); + + if (response.success && response.data) { + const columns = response.data.columns || []; + setAutoFillTableColumns(prev => ({ + ...prev, + [fieldIndex]: columns.map((col: any) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnLabel || col.columnName, + dataType: col.dataType, + })) + })); + console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length); + } else { + console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response); + } + } catch (error) { + console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error); + } + }; + // 🆕 소스 테이블 선택 시 컬럼 로드 const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => { try { @@ -745,15 +780,66 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {/* 테이블명 입력 */} - updateField(index, { autoFillFromTable: e.target.value })} - placeholder="비워두면 주 데이터 (예: item_price)" - className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" - /> + {/* 테이블 선택 드롭다운 */} + + + + + + + + 테이블을 찾을 수 없습니다. + + { + updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined }); + setAutoFillTableColumns(prev => ({ ...prev, [index]: [] })); + }} + className="text-[10px] sm:text-xs" + > + + 원본 테이블 ({config.sourceTable || "미설정"}) + + {allTables.map((table) => ( + { + updateField(index, { autoFillFromTable: value, autoFillFrom: undefined }); + loadAutoFillTableColumns(value, index); + }} + className="text-[10px] sm:text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + +

- 다른 테이블에서 가져올 경우 테이블명 입력 + 다른 테이블에서 가져올 경우 테이블 선택

{/* 필드 선택 */} @@ -764,16 +850,26 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {field.autoFillFrom - ? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom - : "필드 선택 안 함"} + {(() => { + if (!field.autoFillFrom) return "필드 선택 안 함"; + + // 선택된 테이블의 컬럼에서 찾기 + const columns = field.autoFillFromTable + ? (autoFillTableColumns[index] || []) + : (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns); + + const found = columns.find(c => c.columnName === field.autoFillFrom); + return found?.columnLabel || field.autoFillFrom; + })()} - 원본 테이블을 먼저 선택하세요. + + {field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"} + 선택 안 함 - {sourceTableColumns.map((column) => ( - updateField(index, { autoFillFrom: column.columnName })} - className="text-[10px] sm:text-xs" - > - -
-
{column.columnLabel}
-
{column.columnName}
-
-
- ))} + {(() => { + // 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼 + const columns = field.autoFillFromTable + ? (autoFillTableColumns[index] || []) + : (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns); + + return columns.map((column) => ( + updateField(index, { autoFillFrom: value })} + className="text-[10px] sm:text-xs" + > + +
+
{column.columnLabel || column.columnName}
+ {column.dataType &&
{column.dataType}
} +
+
+ )); + })()}
From 6e5e3a04f3f5cab9de53d994c7467826d8453090 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 17:52:40 +0900 Subject: [PATCH 07/30] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EC=9D=98=20=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=A1=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초기 렌더링 시 기존 필드들의 autoFillFromTable이 설정되어 있으면 컬럼 자동 로드 - useEffect로 localFields 초기화 시점에 모든 필드 순회하며 컬럼 로드 - 사용자가 저장된 설정을 열었을 때 즉시 컬럼 목록 표시 문제: 품목정보 테이블을 선택했지만 컬럼이 표시되지 않음 원인: 기존에 설정된 autoFillFromTable에 대한 컬럼이 초기 로드되지 않음 해결: 초기화 useEffect 추가로 기존 설정 복원 --- .../SelectedItemsDetailInputConfigPanel.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 0a05ed0c..80fb210a 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -140,6 +140,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + if (!localFields || localFields.length === 0) return; + + localFields.forEach((field, index) => { + if (field.autoFillFromTable && !autoFillTableColumns[index]) { + console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable); + loadAutoFillTableColumns(field.autoFillFromTable, index); + } + }); + }, []); // 초기 한 번만 실행 + // 🆕 자동 채우기 테이블 선택 시 컬럼 로드 const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => { if (!tableName) { From 62463e1ca8456dc9933bf895fff0cb2408cebcc9 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:00:30 +0900 Subject: [PATCH 08/30] =?UTF-8?q?fix:=20=EB=B6=84=ED=95=A0=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=9D=BC=EB=B2=A8=20=ED=91=9C=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B4=88=EA=B8=B0=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - displayMode가 undefined일 때 기본값 'list' 처리 누락 - 조건문을 (config.rightPanel?.displayMode || 'list') === 'list'로 변경 - 이제 처음 들어갔을 때부터 라벨 표시 설정 UI가 보임 문제: LIST 모드가 기본값인데 초기에는 설정 UI가 안 보이고 테이블 모드로 변경 후 다시 LIST로 바꿔야 보임 원인: undefined === 'list'가 false가 되어 조건문이 작동하지 않음 해결: 기본값 처리 추가 --- .../split-panel-layout/SplitPanelLayoutConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index f59a16e6..9f88e290 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC {/* 요약 표시 설정 (LIST 모드에서만) */} - {config.rightPanel?.displayMode === "list" && ( + {(config.rightPanel?.displayMode || "list") === "list" && (
From 3219015a39555d9daf706dd01dcf4e4924cfeea7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:05:49 +0900 Subject: [PATCH 09/30] =?UTF-8?q?fix:=20=EC=B1=84=EB=B2=88=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EB=A9=94=EB=89=B4=EB=B3=84=20=EA=B2=A9=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: 영업관리 메뉴에서 생성한 채번규칙이 기준정보 메뉴에도 표시됨 원인: - scope_type='table' 규칙을 조회할 때 menu_objid 필터링 없이 모든 규칙을 포함 - 'OR scope_type = 'table'' 조건이 다른 메뉴의 규칙도 반환 수정: - scope_type='table' 규칙도 menu_objid로 필터링하도록 변경 - 'OR (scope_type = 'table' AND menu_objid = ANY(cd /Users/kimjuseok/ERP-node && git commit -m "fix: 채번규칙 메뉴별 격리 문제 해결 문제: 영업관리 메뉴에서 생성한 채번규칙이 기준정보 메뉴에도 표시됨 원인: - scope_type='table' 규칙을 조회할 때 menu_objid 필터링 없이 모든 규칙을 포함 - 'OR scope_type = 'table'' 조건이 다른 메뉴의 규칙도 반환 수정: - scope_type='table' 규칙도 menu_objid로 필터링하도록 변경 - 'OR (scope_type = 'table' AND menu_objid = ANY($1))' 조건으로 메뉴별 격리 - menu_objid IS NULL인 기존 규칙은 하위 호환성을 위해 유지 영향: - 각 메뉴에서 생성한 채번규칙은 해당 메뉴(및 형제 메뉴)에서만 표시 - global 규칙은 여전히 모든 메뉴에서 표시 - 기존 데이터는 영향 없음 (menu_objid NULL 조건 유지)"))' 조건으로 메뉴별 격리 - menu_objid IS NULL인 기존 규칙은 하위 호환성을 위해 유지 영향: - 각 메뉴에서 생성한 채번규칙은 해당 메뉴(및 형제 메뉴)에서만 표시 - global 규칙은 여전히 모든 메뉴에서 표시 - 기존 데이터는 영향 없음 (menu_objid NULL 조건 유지) --- .../src/services/numberingRuleService.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 368559df..cb405b33 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -300,10 +300,9 @@ class NumberingRuleService { FROM numbering_rules WHERE scope_type = 'global' - OR scope_type = 'table' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 + OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -313,9 +312,9 @@ class NumberingRuleService { created_at DESC `; params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids }); + logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함) + // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) query = ` SELECT rule_id AS "ruleId", @@ -336,10 +335,9 @@ class NumberingRuleService { WHERE company_code = $1 AND ( scope_type = 'global' - OR scope_type = 'table' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함 + OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 + OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) ) ORDER BY CASE @@ -350,7 +348,7 @@ class NumberingRuleService { created_at DESC `; params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids }); + logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { From c57e0218feee9d0cd57ed540f5c81f0bf8b2dfd1 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:17:08 +0900 Subject: [PATCH 10/30] =?UTF-8?q?feat:=20select-basic=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20=EB=8B=A4=EC=A4=91=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능: - 설정 패널에 '다중 선택' 체크박스 추가 - multiple 옵션 활성화 시 다중선택 UI 렌더링 - 선택된 항목을 태그 형식으로 표시 - 각 태그에 X 버튼으로 개별 제거 가능 - 드롭다운에 체크박스 표시 - 콤마(,) 구분자로 값 저장/파싱 수정사항: - SelectBasicConfigPanel: 다중 선택 체크박스 추가 - SelectBasicConfigPanel: config 병합 방식으로 변경 (다른 속성 보호) - SelectBasicComponent: 초기값 콤마 구분자로 파싱 - SelectBasicComponent: 외부 value 변경 시 다중선택 배열 동기화 - SelectBasicComponent: 다중선택 UI 렌더링 로직 추가 사용법: 1. 설정 패널에서 '다중 선택' 체크 2. 드롭다운에서 여러 항목 선택 3. 선택된 항목이 태그로 표시되며 X로 제거 가능 4. 저장 시 '값1,값2,값3' 형식으로 저장 --- .../select-basic/SelectBasicComponent.tsx | 105 +++++++++++++++++- .../select-basic/SelectBasicConfigPanel.tsx | 13 ++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 2597a143..c2d4bcb3 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -62,8 +62,14 @@ const SelectBasicComponent: React.FC = ({ const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedLabel, setSelectedLabel] = useState(""); - // multiselect의 경우 배열로 관리 - const [selectedValues, setSelectedValues] = useState([]); + // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱) + const [selectedValues, setSelectedValues] = useState(() => { + const initialValue = externalValue || config?.value || ""; + if (config?.multiple && typeof initialValue === "string" && initialValue) { + return initialValue.split(",").map(v => v.trim()).filter(v => v); + } + return []; + }); // autocomplete의 경우 검색어 관리 const [searchQuery, setSearchQuery] = useState(""); @@ -116,8 +122,14 @@ const SelectBasicComponent: React.FC = ({ // 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리) if (newValue !== selectedValue) { setSelectedValue(newValue); + + // 다중선택 모드인 경우 selectedValues도 업데이트 + if (config?.multiple && typeof newValue === "string" && newValue) { + const values = newValue.split(",").map(v => v.trim()).filter(v => v); + setSelectedValues(values); + } } - }, [externalValue, config?.value]); + }, [externalValue, config?.value, config?.multiple]); // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) @@ -500,6 +512,93 @@ const SelectBasicComponent: React.FC = ({ } // select (기본 선택박스) + // 다중선택 모드인 경우 + if (config?.multiple) { + return ( +
+
!isDesignMode && setIsOpen(true)} + style={{ pointerEvents: isDesignMode ? "none" : "auto" }} + > + {selectedValues.map((val, idx) => { + const opt = allOptions.find((o) => o.value === val); + return ( + + {opt?.label || val} + + + ); + })} + {selectedValues.length === 0 && ( + {placeholder} + )} +
+ {isOpen && !isDesignMode && ( +
+ {isLoadingCodes ? ( +
로딩 중...
+ ) : allOptions.length > 0 ? ( + allOptions.map((option, index) => { + const isSelected = selectedValues.includes(option.value); + return ( +
{ + const newVals = isSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + > +
+ {}} + className="h-4 w-4" + /> + {option.label || option.value} +
+
+ ); + }) + ) : ( +
옵션이 없습니다
+ )} +
+ )} +
+ ); + } + + // 단일선택 모드 return (
= ({ onChange, }) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => { - onChange({ [key]: value }); + // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) + const newConfig = { ...config, [key]: value }; + onChange(newConfig); }; return ( @@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC = ({ onCheckedChange={(checked) => handleChange("readonly", checked)} />
+ +
+ + handleChange("multiple", checked)} + /> +
); }; From 6ea9001a50187f628cae97736cfe2e731a9f6d11 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:21:09 +0900 Subject: [PATCH 11/30] =?UTF-8?q?fix:=20select-basic=20=EB=8B=A4=EC=A4=91?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=98=B5=EC=85=98=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 설정 패널에서 '다중 선택' 체크했지만 실제 화면에서 작동하지 않음 - componentConfig.multiple이 저장되었지만 컴포넌트에서 인식 못함 원인: - DynamicComponentRenderer에서 componentConfig를 spread하여 props로 전달 - 하지만 config.multiple만 체크하고 props.multiple를 체크하지 않음 해결: - isMultiple 변수 추가: props.multiple > config.multiple 우선순위 - 모든 다중선택 로직에서 isMultiple 사용하도록 수정 - 디버깅 로그 추가하여 각 값의 출처 확인 변경사항: - isMultiple = props.multiple ?? config.multiple ?? false - 초기화, 업데이트, 렌더링 로직에 isMultiple 적용 - 상세 디버깅 로그로 문제 추적 가능 --- .../select-basic/SelectBasicComponent.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index c2d4bcb3..7e905912 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -55,6 +55,25 @@ const SelectBasicComponent: React.FC = ({ // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) const config = (props as any).webTypeConfig || componentConfig || {}; + // 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위 + const isMultiple = (props as any).multiple ?? config?.multiple ?? false; + + // 🔍 디버깅: config 및 multiple 확인 + useEffect(() => { + console.log("🔍 [SelectBasicComponent] config 및 multiple 확인:", { + componentId: component.id, + "config (전체)": config, + "config.multiple": config?.multiple, + "props.multiple": (props as any).multiple, + "componentConfig (prop)": componentConfig, + "componentConfig.multiple": componentConfig?.multiple, + "component.componentConfig": component.componentConfig, + "component.componentConfig.multiple": component.componentConfig?.multiple, + "webTypeConfig": (props as any).webTypeConfig, + "최종 isMultiple 값": isMultiple, + }); + }, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]); + // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) const webType = component.componentConfig?.webType || "select"; @@ -65,7 +84,7 @@ const SelectBasicComponent: React.FC = ({ // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱) const [selectedValues, setSelectedValues] = useState(() => { const initialValue = externalValue || config?.value || ""; - if (config?.multiple && typeof initialValue === "string" && initialValue) { + if (isMultiple && typeof initialValue === "string" && initialValue) { return initialValue.split(",").map(v => v.trim()).filter(v => v); } return []; @@ -124,12 +143,12 @@ const SelectBasicComponent: React.FC = ({ setSelectedValue(newValue); // 다중선택 모드인 경우 selectedValues도 업데이트 - if (config?.multiple && typeof newValue === "string" && newValue) { + if (isMultiple && typeof newValue === "string" && newValue) { const values = newValue.split(",").map(v => v.trim()).filter(v => v); setSelectedValues(values); } } - }, [externalValue, config?.value, config?.multiple]); + }, [externalValue, config?.value, isMultiple]); // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) @@ -513,7 +532,7 @@ const SelectBasicComponent: React.FC = ({ // select (기본 선택박스) // 다중선택 모드인 경우 - if (config?.multiple) { + if (isMultiple) { return (
Date: Thu, 20 Nov 2025 18:23:29 +0900 Subject: [PATCH 12/30] =?UTF-8?q?debug:=20select-basic=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=EC=84=A0=ED=83=9D=20=EB=94=94=EB=B2=84=EA=B9=85=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 더 명확한 로그 출력: - 단계별로 구분된 로그 - 각 props 출처별로 명확히 표시 - 최종 isMultiple 값 강조 - 활성화/비활성화 상태 명확히 표시 사용자는 브라우저 콘솔에서 다음을 확인: 1. '🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========' 로그 찾기 2. '최종 isMultiple 값' 확인 3. 각 props 출처의 multiple 값 확인 4. ✅/❌ 상태 메시지 확인 --- .../select-basic/SelectBasicComponent.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 7e905912..2f1cdaa3 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -60,18 +60,26 @@ const SelectBasicComponent: React.FC = ({ // 🔍 디버깅: config 및 multiple 확인 useEffect(() => { - console.log("🔍 [SelectBasicComponent] config 및 multiple 확인:", { - componentId: component.id, - "config (전체)": config, - "config.multiple": config?.multiple, - "props.multiple": (props as any).multiple, - "componentConfig (prop)": componentConfig, - "componentConfig.multiple": componentConfig?.multiple, - "component.componentConfig": component.componentConfig, - "component.componentConfig.multiple": component.componentConfig?.multiple, - "webTypeConfig": (props as any).webTypeConfig, - "최종 isMultiple 값": isMultiple, - }); + console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 =========="); + console.log(" 컴포넌트 ID:", component.id); + console.log(" 최종 isMultiple 값:", isMultiple); + console.log(" ----------------------------------------"); + console.log(" props.multiple:", (props as any).multiple); + console.log(" config.multiple:", config?.multiple); + console.log(" componentConfig.multiple:", componentConfig?.multiple); + console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple); + console.log(" ----------------------------------------"); + console.log(" config 전체:", config); + console.log(" componentConfig 전체:", componentConfig); + console.log(" component.componentConfig 전체:", component.componentConfig); + console.log(" ======================================="); + + // 다중선택이 활성화되었는지 알림 + if (isMultiple) { + console.log("✅ 다중선택 모드 활성화됨!"); + } else { + console.log("❌ 단일선택 모드 (다중선택 비활성화)"); + } }, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]); // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) From f765ac4a47a3d6c87401b9f0fede070449a69383 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:26:19 +0900 Subject: [PATCH 13/30] =?UTF-8?q?debug:=20SelectBasicComponent=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=ED=99=95=EC=9D=B8=EC=9A=A9=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 다중선택 설정했지만 UI에 반영 안됨 - 디버깅 로그가 콘솔에 전혀 안 보임 원인 추정: - SelectBasicComponent가 아예 렌더링 안되고 있을 가능성 - 또는 다른 select 컴포넌트가 대신 렌더링될 가능성 테스트: - 최상단에 눈에 띄는 로그 (🚨🚨🚨) 추가 - componentId, componentType, columnName, multiple 값 출력 - 이 로그가 안 보이면 다른 컴포넌트가 렌더링되는 것 --- .../components/select-basic/SelectBasicComponent.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 2f1cdaa3..9f151509 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -50,6 +50,15 @@ const SelectBasicComponent: React.FC = ({ menuObjid, // 🆕 메뉴 OBJID ...props }) => { + // 🚨 최초 렌더링 확인용 (테스트 후 제거) + console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", { + componentId: component.id, + componentType: (component as any).componentType, + columnName: (component as any).columnName, + "props.multiple": (props as any).multiple, + "componentConfig.multiple": componentConfig?.multiple, + }); + const [isOpen, setIsOpen] = useState(false); // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) From 9b65c1cbff195d7340954df543a38b0771bfe958 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:29:29 +0900 Subject: [PATCH 14/30] =?UTF-8?q?debug:=20DynamicComponentRenderer?= =?UTF-8?q?=EC=97=90=EC=84=9C=20select-basic=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - SelectBasicComponent 렌더링 로그가 전혀 안 보임 - select-basic이 ComponentRegistry에 등록되었는지 확인 필요 디버깅: - componentType이 'select-basic'일 때 조회 결과 로그 - found: true/false로 등록 여부 확인 - componentConfig 값도 함께 출력 예상 결과: - found: false면 등록 실패 - found: true면 다른 문제 (렌더링 과정에서 문제) --- frontend/lib/registry/DynamicComponentRenderer.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 4c95ceb8..5273732e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -213,6 +213,16 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); + // 🔍 디버깅: select-basic 조회 결과 확인 + if (componentType === "select-basic") { + console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { + componentType, + found: !!newComponent, + componentId: component.id, + componentConfig: component.componentConfig, + }); + } + if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { From 87fbf5b8589bfd58bc4863deb1ce14b66efb2f34 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:31:24 +0900 Subject: [PATCH 15/30] =?UTF-8?q?fix:=20select-basic=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EA=B0=80=20CategorySelectComponent=EC=97=90?= =?UTF-8?q?=20=EA=B0=80=EB=A1=9C=EC=B1=84=EC=A7=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - componentType이 'select-basic'이지만 webType이 'category'일 때 - DynamicComponentRenderer가 무조건 CategorySelectComponent 사용 - select-basic의 multiple 설정이 무시됨 원인: - 152줄에서 webType === 'category' 조건만 체크 - componentType을 확인하지 않아 select-basic도 가로챔 해결: - componentType !== 'select-basic' 조건 추가 - select-basic은 카테고리 조건을 건너뛰고 ComponentRegistry로 진행 - 다중선택 등 select-basic의 고급 기능 사용 가능 변경사항: - DynamicComponentRenderer.tsx 152줄 - 카테고리 조건에 componentType 체크 추가 --- frontend/lib/registry/DynamicComponentRenderer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 5273732e..e2fe8abe 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -150,7 +150,8 @@ export const DynamicComponentRenderer: React.FC = const columnName = (component as any).columnName; // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - if ((inputType === "category" || webType === "category") && tableName && columnName) { + // ⚠️ 단, componentType이 "select-basic"인 경우는 제외 (다중선택 등 고급 기능 지원) + if ((inputType === "category" || webType === "category") && tableName && columnName && componentType !== "select-basic") { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; From dd568b72353342b27bb639451b93fb28a5d13897 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:31:50 +0900 Subject: [PATCH 16/30] =?UTF-8?q?fix:=20select-basic=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=A1=B0=EA=B1=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 이전 커밋에서 로직을 반대로 작성 - componentType !== 'select-basic'로 했지만 - componentType === 'select-basic'일 때 건너뛰어야 함 수정: - componentType === 'select-basic'이면 통과 (아무것도 안 함) - 그 외 카테고리는 CategorySelectComponent 사용 로직: if (category && componentType === 'select-basic') { // 통과 - ComponentRegistry로 진행 } else if (category) { // CategorySelectComponent 사용 } --- frontend/lib/registry/DynamicComponentRenderer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e2fe8abe..3792518e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -150,8 +150,10 @@ export const DynamicComponentRenderer: React.FC = const columnName = (component as any).columnName; // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - // ⚠️ 단, componentType이 "select-basic"인 경우는 제외 (다중선택 등 고급 기능 지원) - if ((inputType === "category" || webType === "category") && tableName && columnName && componentType !== "select-basic") { + // ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) + if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") { + // select-basic은 ComponentRegistry에서 처리하도록 아래로 통과 + } else if ((inputType === "category" || webType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; From fc16f2764076a3c54d0d272686579b0d7c0ef2d8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 18:35:48 +0900 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20select-basic=EC=97=90=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC(category)=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EB=A1=9C=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - select-basic이 webType='category'일 때 옵션이 안 보임 - CATEGORY_218152 같은 코드값만 표시됨 - 체크박스는 보이지만 라벨이 비어있음 원인: - select-basic은 useCodeOptions만 사용 (code 타입용) - category 타입은 getCategoryValues API 필요 해결: 1. categoryOptions 상태 추가 2. webType === 'category'일 때 getCategoryValues 호출 3. getAllOptions에 categoryOptions 포함 4. 로딩 상태에 isLoadingCategories 추가 디버깅: - 카테고리 로딩 시작/완료 로그 - API 응답 로그 - 최종 allOptions 로그 추가 다음 단계: - 콘솔에서 categoryOptions가 제대로 로드되는지 확인 --- .../select-basic/SelectBasicComponent.tsx | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 9f151509..0e438da6 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -138,6 +138,53 @@ const SelectBasicComponent: React.FC = ({ isFetching, } = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid); + // 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩 + const [categoryOptions, setCategoryOptions] = useState([]); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + + useEffect(() => { + if (webType === "category" && component.tableName && component.columnName) { + console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", { + tableName: component.tableName, + columnName: component.columnName, + webType, + }); + + setIsLoadingCategories(true); + + import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => { + getCategoryValues(component.tableName!, component.columnName!) + .then((response) => { + console.log("🔍 [SelectBasic] 카테고리 API 응답:", response); + + if (response.success && response.data) { + const activeValues = response.data.filter((v) => v.isActive !== false); + const options = activeValues.map((v) => ({ + value: v.categoryValue, + label: v.categoryLabel || v.categoryValue, + })); + + console.log("✅ [SelectBasic] 카테고리 옵션 설정:", { + activeValuesCount: activeValues.length, + options, + sampleOption: options[0], + }); + + setCategoryOptions(options); + } else { + console.error("❌ [SelectBasic] 카테고리 응답 실패:", response); + } + }) + .catch((error) => { + console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error); + }) + .finally(() => { + setIsLoadingCategories(false); + }); + }); + } + }, [webType, component.tableName, component.columnName]); + // 디버깅: menuObjid가 제대로 전달되는지 확인 useEffect(() => { if (codeCategory && codeCategory !== "none") { @@ -176,7 +223,7 @@ const SelectBasicComponent: React.FC = ({ useEffect(() => { const getAllOptions = () => { const configOptions = config.options || []; - return [...codeOptions, ...configOptions]; + return [...codeOptions, ...categoryOptions, ...configOptions]; }; const options = getAllOptions(); @@ -252,12 +299,24 @@ const SelectBasicComponent: React.FC = ({ // 모든 옵션 가져오기 const getAllOptions = () => { const configOptions = config.options || []; - return [...codeOptions, ...configOptions]; + return [...codeOptions, ...categoryOptions, ...configOptions]; }; const allOptions = getAllOptions(); const placeholder = componentConfig.placeholder || "선택하세요"; + // 🔍 디버깅: 최종 옵션 확인 + useEffect(() => { + if (webType === "category" && allOptions.length > 0) { + console.log("🔍 [SelectBasic] 최종 allOptions:", { + count: allOptions.length, + categoryOptionsCount: categoryOptions.length, + codeOptionsCount: codeOptions.length, + sampleOptions: allOptions.slice(0, 3), + }); + } + }, [webType, allOptions.length, categoryOptions.length, codeOptions.length]); + // DOM props에서 React 전용 props 필터링 const { component: _component, @@ -590,7 +649,7 @@ const SelectBasicComponent: React.FC = ({
{isOpen && !isDesignMode && (
- {isLoadingCodes ? ( + {(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => { From 114a807d7985a763589e8ceb2a34e7e8e6e2ae37 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 09:39:09 +0900 Subject: [PATCH 18/30] =?UTF-8?q?debug:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20API=20=EC=9D=91=EB=8B=B5=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - value: undefined, label: undefined로 나옴 - v.categoryValue, v.categoryLabel이 존재하지 않음 디버깅: - API 응답의 첫 번째 항목 전체 출력 - 객체의 모든 키 목록 출력 - 여러 가능한 속성명 시도: - category_value / categoryValue / value - category_label / categoryLabel / label 다음 단계: - 콘솔에서 원본 데이터 구조 확인 - 실제 속성명에 맞게 매핑 수정 --- .../components/select-basic/SelectBasicComponent.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 0e438da6..497dd5c0 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -158,10 +158,15 @@ const SelectBasicComponent: React.FC = ({ console.log("🔍 [SelectBasic] 카테고리 API 응답:", response); if (response.success && response.data) { + console.log("🔍 [SelectBasic] 원본 데이터 샘플:", { + firstItem: response.data[0], + keys: response.data[0] ? Object.keys(response.data[0]) : [], + }); + const activeValues = response.data.filter((v) => v.isActive !== false); const options = activeValues.map((v) => ({ - value: v.categoryValue, - label: v.categoryLabel || v.categoryValue, + value: v.category_value || v.categoryValue || v.value, + label: v.category_label || v.categoryLabel || v.label || v.category_value || v.categoryValue || v.value, })); console.log("✅ [SelectBasic] 카테고리 옵션 설정:", { From f4d27f51a39fa524f7c1eb63014c9e29e3e9142b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 09:40:24 +0900 Subject: [PATCH 19/30] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=92=20=EB=A7=A4=ED=95=91=EC=9D=84=20=EC=98=AC?= =?UTF-8?q?=EB=B0=94=EB=A5=B8=20=EC=86=8D=EC=84=B1=EB=AA=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - value: undefined, label: undefined - 잘못된 속성명 사용 (categoryValue, categoryLabel) 원인: - API 응답 실제 구조: - valueCode: 'CATEGORY_154396' - valueLabel: '대기' 해결: - v.categoryValue → v.valueCode - v.categoryLabel → v.valueLabel 이제 다중선택 카테고리 select가 완벽히 작동합니다: ✅ 다중선택 모드 활성화 ✅ 카테고리 옵션 로딩 ✅ 라벨 정상 표시 ✅ 콤마 구분자로 저장 --- .../registry/components/select-basic/SelectBasicComponent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 497dd5c0..5915a58c 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -165,8 +165,8 @@ const SelectBasicComponent: React.FC = ({ const activeValues = response.data.filter((v) => v.isActive !== false); const options = activeValues.map((v) => ({ - value: v.category_value || v.categoryValue || v.value, - label: v.category_label || v.categoryLabel || v.label || v.category_value || v.categoryValue || v.value, + value: v.valueCode, + label: v.valueLabel || v.valueCode, })); console.log("✅ [SelectBasic] 카테고리 옵션 설정:", { From bb49073bf7be47f665de13cb8b16731c11bf0ebb Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 10:03:26 +0900 Subject: [PATCH 20/30] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=8B=A4=EC=A4=91=20=EA=B0=92=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=ED=91=9C=EC=8B=9C=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 테이블에서 'CATEGORY_218152,CATEGORY_205381' 같은 다중 값이 - 배지로 표시되지 않고 코드값 그대로 보임 원인: - formatCellValue의 카테고리 렌더링이 단일 값만 처리 - 콤마로 구분된 다중 값 파싱 로직 없음 해결: 1. 콤마 구분자 감지 및 값 배열로 분리 2. 단일 값: 기존 로직 유지 (단일 배지) 3. 다중 값: flex-wrap gap-1로 여러 배지 렌더링 4. 각 배지는 매핑된 라벨과 색상 사용 결과: ✅ 다중선택 저장된 데이터가 테이블에서 여러 배지로 표시됨 ✅ 각 배지에 올바른 색상과 라벨 적용 ✅ 단일 값도 기존처럼 정상 작동 --- .../select-basic/SelectBasicComponent.tsx | 39 ++++++++-- .../table-list/TableListComponent.tsx | 76 ++++++++++++++----- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 5915a58c..7e12dda9 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -207,14 +207,39 @@ const SelectBasicComponent: React.FC = ({ // 외부 value prop 변경 시 selectedValue 업데이트 useEffect(() => { const newValue = externalValue || config?.value || ""; - // 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리) - if (newValue !== selectedValue) { - setSelectedValue(newValue); - - // 다중선택 모드인 경우 selectedValues도 업데이트 - if (isMultiple && typeof newValue === "string" && newValue) { + + console.log("🔍 [SelectBasic] 외부 값 변경 감지:", { + componentId: component.id, + columnName: (component as any).columnName, + isMultiple, + newValue, + selectedValue, + selectedValues, + externalValue, + "config.value": config?.value, + }); + + // 다중선택 모드인 경우 + if (isMultiple) { + if (typeof newValue === "string" && newValue) { const values = newValue.split(",").map(v => v.trim()).filter(v => v); - setSelectedValues(values); + const currentValuesStr = selectedValues.join(","); + + if (newValue !== currentValuesStr) { + console.log("✅ [SelectBasic] 다중선택 값 업데이트:", { + from: selectedValues, + to: values, + }); + setSelectedValues(values); + } + } else if (!newValue && selectedValues.length > 0) { + console.log("✅ [SelectBasic] 다중선택 값 초기화"); + setSelectedValues([]); + } + } else { + // 단일선택 모드인 경우 + if (newValue !== selectedValue) { + setSelectedValue(newValue); } } }, [externalValue, config?.value, isMultiple]); diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4a6aec26..12bdb7d1 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1425,33 +1425,73 @@ export const TableListComponent: React.FC = ({ ); } - // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원) + // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) if (inputType === "category") { if (!value) return ""; const mapping = categoryMappings[column.columnName]; - const categoryData = mapping?.[String(value)]; + const { Badge } = require("@/components/ui/badge"); - // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 - const displayLabel = categoryData?.label || String(value); - const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 + // 다중 값 처리: 콤마로 구분된 값들을 분리 + const valueStr = String(value); + const values = valueStr.includes(",") + ? valueStr.split(",").map(v => v.trim()).filter(v => v) + : [valueStr]; - // 배지 없음 옵션: color가 "none"이면 텍스트만 표시 - if (displayColor === "none") { - return {displayLabel}; + // 단일 값인 경우 (기존 로직) + if (values.length === 1) { + const categoryData = mapping?.[values[0]]; + const displayLabel = categoryData?.label || values[0]; + const displayColor = categoryData?.color || "#64748b"; + + if (displayColor === "none") { + return {displayLabel}; + } + + return ( + + {displayLabel} + + ); } - const { Badge } = require("@/components/ui/badge"); + // 다중 값인 경우: 여러 배지 렌더링 return ( - - {displayLabel} - +
+ {values.map((val, idx) => { + const categoryData = mapping?.[val]; + const displayLabel = categoryData?.label || val; + const displayColor = categoryData?.color || "#64748b"; + + if (displayColor === "none") { + return ( + + {displayLabel} + {idx < values.length - 1 && ", "} + + ); + } + + return ( + + {displayLabel} + + ); + })} +
); } From c70998fa4f37e19de8e07ea461e7d81b8a0ab19e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 14:37:09 +0900 Subject: [PATCH 21/30] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EB=8A=A5=20-=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B0=B8=EC=A1=B0=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제: 화면 복사 시 참조되는 화면이 아직 복사되지 않아 screenIdMap에 매핑 정보가 없었음 - 해결: 2단계 복사 방식 도입 1단계: 모든 screen_definitions 먼저 복사하여 screenIdMap 완성 2단계: screen_layouts 복사하면서 완성된 screenIdMap으로 참조 업데이트 - 결과: targetScreenId가 올바르게 새 회사의 화면 ID로 매핑됨 (예: 149 → 517) - 추가: 화면 수집 시 문자열 타입 ID도 올바르게 파싱하도록 개선 - 추가: 참조 화면 발견 및 업데이트 로그 추가 관련 파일: - backend-node/src/services/menuCopyService.ts - db/migrations/1003_add_source_menu_objid_to_menu_info.sql - db/scripts/cleanup_company_11_*.sql --- .../src/controllers/adminController.ts | 82 + backend-node/src/routes/adminRoutes.ts | 2 + backend-node/src/services/menuCopyService.ts | 1439 ++++++++++++++ db/migrations/README_1003.md | 184 ++ db/migrations/RUN_MIGRATION_1003.md | 146 ++ db/scripts/README_cleanup.md | 126 ++ docs/메뉴_복사_기능_구현_계획서.md | 1660 +++++++++++++++++ frontend/components/admin/MenuCopyDialog.tsx | 262 +++ frontend/components/admin/MenuManagement.tsx | 30 + frontend/components/admin/MenuTable.tsx | 70 +- frontend/lib/api/menu.ts | 36 + 11 files changed, 4021 insertions(+), 16 deletions(-) create mode 100644 backend-node/src/services/menuCopyService.ts create mode 100644 db/migrations/README_1003.md create mode 100644 db/migrations/RUN_MIGRATION_1003.md create mode 100644 db/scripts/README_cleanup.md create mode 100644 docs/메뉴_복사_기능_구현_계획서.md create mode 100644 frontend/components/admin/MenuCopyDialog.tsx diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b3ecbffb..bfc1f3b1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; import { validateBusinessNumber } from "../utils/businessNumberValidator"; +import { MenuCopyService } from "../services/menuCopyService"; /** * 관리자 메뉴 목록 조회 @@ -3253,3 +3254,84 @@ export async function getTableSchema( }); } } + +/** + * 메뉴 복사 + * POST /api/admin/menus/:menuObjid/copy + */ +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + const userType = req.user!.userType; + const isSuperAdmin = req.user!.isSuperAdmin; + + logger.info(` +=== 메뉴 복사 API 호출 === + menuObjid: ${menuObjid} + targetCompanyCode: ${targetCompanyCode} + userId: ${userId} + userCompanyCode: ${userCompanyCode} + userType: ${userType} + isSuperAdmin: ${isSuperAdmin} + `); + + // 권한 체크: 최고 관리자만 가능 + if (!isSuperAdmin && userType !== "SUPER_ADMIN") { + logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", + error: { + code: "FORBIDDEN", + details: "Only super admin can copy menus", + }, + }); + return; + } + + // 필수 파라미터 검증 + if (!menuObjid || !targetCompanyCode) { + res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다", + error: { + code: "MISSING_PARAMETERS", + details: "menuObjid and targetCompanyCode are required", + }, + }); + return; + } + + // 메뉴 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid, 10), + targetCompanyCode, + userId + ); + + logger.info("✅ 메뉴 복사 API 성공"); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + } catch (error: any) { + logger.error("❌ 메뉴 복사 API 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: { + code: "MENU_COPY_ERROR", + details: error.message || "Unknown error", + }, + }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 378a38d9..188e5580 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -8,6 +8,7 @@ import { deleteMenu, // 메뉴 삭제 deleteMenusBatch, // 메뉴 일괄 삭제 toggleMenuStatus, // 메뉴 상태 토글 + copyMenu, // 메뉴 복사 getUserList, getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 @@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 +router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) router.put("/menus/:menuId", updateMenu); // 메뉴 수정 router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글 router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts new file mode 100644 index 00000000..4a9fc8bf --- /dev/null +++ b/backend-node/src/services/menuCopyService.ts @@ -0,0 +1,1439 @@ +import { PoolClient } from "pg"; +import { query, pool } from "../database/db"; +import logger from "../utils/logger"; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + success: boolean; + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings: string[]; +} + +/** + * 메뉴 정보 + */ +interface Menu { + objid: number; + menu_type: number | null; + parent_obj_id: number | null; + menu_name_kor: string | null; + menu_name_eng: string | null; + seq: number | null; + menu_url: string | null; + menu_desc: string | null; + writer: string | null; + regdate: Date | null; + status: string | null; + system_name: string | null; + company_code: string | null; + lang_key: string | null; + lang_key_desc: string | null; + screen_code: string | null; + menu_code: string | null; +} + +/** + * 화면 정의 + */ +interface ScreenDefinition { + screen_id: number; + screen_name: string; + screen_code: string; + table_name: string; + company_code: string; + description: string | null; + is_active: string; + layout_metadata: any; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 화면 레이아웃 + */ +interface ScreenLayout { + layout_id: number; + screen_id: number; + component_type: string; + component_id: string; + parent_id: string | null; + position_x: number; + position_y: number; + width: number; + height: number; + properties: any; + display_order: number; + layout_type: string | null; + layout_config: any; + zones_config: any; + zone_id: string | null; +} + +/** + * 플로우 정의 + */ +interface FlowDefinition { + id: number; + name: string; + description: string | null; + table_name: string; + is_active: boolean; + company_code: string; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 플로우 스텝 + */ +interface FlowStep { + id: number; + flow_definition_id: number; + step_name: string; + step_order: number; + condition_json: any; + color: string | null; + position_x: number | null; + position_y: number | null; + table_name: string | null; + move_type: string | null; + status_column: string | null; + status_value: string | null; + target_table: string | null; + field_mappings: any; + required_fields: any; + integration_type: string | null; + integration_config: any; + display_config: any; +} + +/** + * 플로우 스텝 연결 + */ +interface FlowStepConnection { + id: number; + flow_definition_id: number; + from_step_id: number; + to_step_id: number; + label: string | null; +} + +/** + * 코드 카테고리 + */ +interface CodeCategory { + category_code: string; + category_name: string; + category_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 코드 정보 + */ +interface CodeInfo { + code_category: string; + code_value: string; + code_name: string; + code_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 메뉴 복사 서비스 + */ +export class MenuCopyService { + /** + * 메뉴 트리 수집 (재귀) + */ + private async collectMenuTree( + rootMenuObjid: number, + client: PoolClient + ): Promise { + logger.info(`📂 메뉴 트리 수집 시작: rootMenuObjid=${rootMenuObjid}`); + + const result: Menu[] = []; + const visited = new Set(); + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + + if (visited.has(currentObjid)) continue; + visited.add(currentObjid); + + // 현재 메뉴 조회 + const menuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [currentObjid] + ); + + if (menuResult.rows.length === 0) { + logger.warn(`⚠️ 메뉴를 찾을 수 없음: objid=${currentObjid}`); + continue; + } + + const menu = menuResult.rows[0]; + result.push(menu); + + // 자식 메뉴 조회 + const childrenResult = await client.query( + `SELECT * FROM menu_info WHERE parent_obj_id = $1 ORDER BY seq`, + [currentObjid] + ); + + for (const child of childrenResult.rows) { + if (!visited.has(child.objid)) { + stack.push(child.objid); + } + } + } + + logger.info(`✅ 메뉴 트리 수집 완료: ${result.length}개`); + return result; + } + + /** + * 화면 레이아웃에서 참조 화면 추출 + */ + private extractReferencedScreens(layouts: ScreenLayout[]): number[] { + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + if (!props) continue; + + // 1) 모달 버튼 (숫자 또는 문자열) + if (props?.componentConfig?.action?.targetScreenId) { + const targetId = props.componentConfig.action.targetScreenId; + const numId = + typeof targetId === "number" ? targetId : parseInt(targetId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + + // 2) 조건부 컨테이너 (숫자 또는 문자열) + if (props?.sections && Array.isArray(props.sections)) { + for (const section of props.sections) { + if (section.screenId) { + const screenId = section.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + } + } + } + + return referenced; + } + + /** + * 화면 수집 (중복 제거, 재귀적 참조 추적) + */ + private async collectScreens( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise> { + logger.info( + `📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}` + ); + + const screenIds = new Set(); + const visited = new Set(); + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); + } + } + + logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + + while (queue.length > 0) { + const screenId = queue.shift()!; + + if (visited.has(screenId)) continue; + visited.add(screenId); + + // 화면 레이아웃 조회 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + // 참조 화면 추출 + const referencedScreens = this.extractReferencedScreens( + layoutsResult.rows + ); + + if (referencedScreens.length > 0) { + logger.info( + ` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}` + ); + } + + for (const refId of referencedScreens) { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + } + } + + logger.info(`✅ 화면 수집 완료: ${screenIds.size}개 (참조 포함)`); + return screenIds; + } + + /** + * 플로우 수집 + */ + private async collectFlows( + screenIds: Set, + client: PoolClient + ): Promise> { + logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); + + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layoutsResult = await client.query( + `SELECT properties FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + for (const layout of layoutsResult.rows) { + const props = layout.properties; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + return flowIds; + } + + /** + * 코드 수집 + */ + private async collectCodes( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { + logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); + + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const catsResult = await client.query( + `SELECT * FROM code_category + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + categories.push(...catsResult.rows); + + // 각 카테고리의 코드 정보 + for (const cat of catsResult.rows) { + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, + [cat.category_code, menuObjid, sourceCompanyCode] + ); + codes.push(...codesResult.rows); + } + } + + logger.info( + `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` + ); + return { categories, codes }; + } + + /** + * 다음 메뉴 objid 생성 + */ + private async getNextMenuObjid(client: PoolClient): Promise { + const result = await client.query<{ max_objid: string }>( + `SELECT COALESCE(MAX(objid), 0)::text as max_objid FROM menu_info` + ); + return parseInt(result.rows[0].max_objid, 10) + 1; + } + + /** + * 고유 화면 코드 생성 + */ + private async generateUniqueScreenCode( + targetCompanyCode: string, + client: PoolClient + ): Promise { + // {company_code}_{순번} 형식 + const prefix = targetCompanyCode === "*" ? "*" : targetCompanyCode; + + const result = await client.query<{ max_num: string }>( + `SELECT COALESCE( + MAX( + CASE + WHEN screen_code ~ '^${prefix}_[0-9]+$' + THEN CAST(SUBSTRING(screen_code FROM '${prefix}_([0-9]+)') AS INTEGER) + ELSE 0 + END + ), 0 + )::text as max_num + FROM screen_definitions + WHERE company_code = $1`, + [targetCompanyCode] + ); + + const maxNum = parseInt(result.rows[0].max_num, 10); + const newNum = maxNum + 1; + return `${prefix}_${String(newNum).padStart(3, "0")}`; + } + + /** + * properties 내부 참조 업데이트 + */ + /** + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + */ + private updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map + ): any { + if (!properties) return properties; + + // 깊은 복사 + const updated = JSON.parse(JSON.stringify(properties)); + + // 재귀적으로 객체/배열 탐색 + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + + return updated; + } + + /** + * 재귀적으로 모든 ID 참조 업데이트 + */ + private recursiveUpdateReferences( + obj: any, + screenIdMap: Map, + flowIdMap: Map, + path: string = "" + ): void { + if (!obj || typeof obj !== "object") return; + + // 배열인 경우 + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + this.recursiveUpdateReferences( + item, + screenIdMap, + flowIdMap, + `${path}[${index}]` + ); + }); + return; + } + + // 객체인 경우 - 키별로 처리 + for (const key of Object.keys(obj)) { + const value = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + + // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + if ( + key === "screen_id" || + key === "screenId" || + key === "targetScreenId" + ) { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = screenIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.info( + ` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // flowId 매핑 (숫자 또는 숫자 문자열) + if (key === "flowId") { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = flowIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.debug( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // 재귀 호출 + if (typeof value === "object" && value !== null) { + this.recursiveUpdateReferences( + value, + screenIdMap, + flowIdMap, + currentPath + ); + } + } + } + + /** + * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) + * + * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + */ + private async deleteExistingCopy( + sourceMenuObjid: number, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); + + // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + const sourceMenuResult = await client.query( + `SELECT menu_name_kor, menu_name_eng + FROM menu_info + WHERE objid = $1`, + [sourceMenuObjid] + ); + + if (sourceMenuResult.rows.length === 0) { + logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다"); + return; + } + + const sourceMenu = sourceMenuResult.rows[0]; + + // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) + const existingMenuResult = await client.query<{ objid: number }>( + `SELECT objid + FROM menu_info + WHERE source_menu_objid = $1 + AND company_code = $2 + AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + [sourceMenuObjid, targetCompanyCode] + ); + + if (existingMenuResult.rows.length === 0) { + logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다"); + return; + } + + const existingMenuObjid = existingMenuResult.rows[0].objid; + logger.info( + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + ); + + // 3. 기존 메뉴 트리 수집 + const existingMenus = await this.collectMenuTree(existingMenuObjid, client); + const existingMenuIds = existingMenus.map((m) => m.objid); + + logger.info(`📊 삭제 대상: 메뉴 ${existingMenus.length}개`); + + // 4. 관련 화면 ID 수집 + const existingScreenIds = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + + const screenIds = existingScreenIds.rows.map((r) => r.screen_id); + + // 5. 삭제 순서 (외래키 제약 고려) + + // 5-1. 화면 레이아웃 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + logger.info(` ✅ 화면 레이아웃 삭제 완료`); + } + + // 5-2. 화면-메뉴 할당 삭제 + await client.query( + `DELETE FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); + + // 5-3. 화면 정의 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screenIds, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료`); + } + + // 5-4. 메뉴 권한 삭제 + await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ + existingMenuIds, + ]); + logger.info(` ✅ 메뉴 권한 삭제 완료`); + + // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + for (let i = existingMenus.length - 1; i >= 0; i--) { + await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ + existingMenus[i].objid, + ]); + } + logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); + + logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); + } + + /** + * 메뉴 복사 (메인 함수) + */ + async copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string + ): Promise { + logger.info(` +🚀 ============================================ + 메뉴 복사 시작 + 원본 메뉴: ${sourceMenuObjid} + 대상 회사: ${targetCompanyCode} + 사용자: ${userId} +============================================ + `); + + const warnings: string[] = []; + const client = await pool.connect(); + + try { + // 트랜잭션 시작 + await client.query("BEGIN"); + logger.info("📦 트랜잭션 시작"); + + // === 0단계: 기존 복사본 삭제 (덮어쓰기) === + await this.deleteExistingCopy(sourceMenuObjid, targetCompanyCode, client); + + // === 1단계: 수집 (Collection Phase) === + logger.info("\n📂 [1단계] 데이터 수집"); + + const menus = await this.collectMenuTree(sourceMenuObjid, client); + const sourceCompanyCode = menus[0].company_code!; + + const screenIds = await this.collectScreens( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const flowIds = await this.collectFlows(screenIds, client); + + const codes = await this.collectCodes( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + logger.info(` +📊 수집 완료: + - 메뉴: ${menus.length}개 + - 화면: ${screenIds.size}개 + - 플로우: ${flowIds.size}개 + - 코드 카테고리: ${codes.categories.length}개 + - 코드: ${codes.codes.length}개 + `); + + // === 2단계: 플로우 복사 === + logger.info("\n🔄 [2단계] 플로우 복사"); + const flowIdMap = await this.copyFlows( + flowIds, + targetCompanyCode, + userId, + client + ); + + // === 3단계: 화면 복사 === + logger.info("\n📄 [3단계] 화면 복사"); + const screenIdMap = await this.copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, + userId, + client + ); + + // === 4단계: 메뉴 복사 === + logger.info("\n📂 [4단계] 메뉴 복사"); + const menuIdMap = await this.copyMenus( + menus, + targetCompanyCode, + screenIdMap, + userId, + client + ); + + // === 5단계: 화면-메뉴 할당 === + logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + await this.createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // === 6단계: 코드 복사 === + logger.info("\n📋 [6단계] 코드 복사"); + await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); + + // 커밋 + await client.query("COMMIT"); + logger.info("✅ 트랜잭션 커밋 완료"); + + const result: MenuCopyResult = { + success: true, + copiedMenus: menuIdMap.size, + copiedScreens: screenIdMap.size, + copiedFlows: flowIdMap.size, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + menuIdMap: Object.fromEntries(menuIdMap), + screenIdMap: Object.fromEntries(screenIdMap), + flowIdMap: Object.fromEntries(flowIdMap), + warnings, + }; + + logger.info(` +🎉 ============================================ + 메뉴 복사 완료! + - 메뉴: ${result.copiedMenus}개 + - 화면: ${result.copiedScreens}개 + - 플로우: ${result.copiedFlows}개 + - 코드 카테고리: ${result.copiedCategories}개 + - 코드: ${result.copiedCodes}개 +============================================ + `); + + return result; + } catch (error: any) { + // 롤백 + await client.query("ROLLBACK"); + logger.error("❌ 메뉴 복사 실패, 롤백됨:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 플로우 복사 + */ + private async copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise> { + const flowIdMap = new Map(); + + if (flowIds.size === 0) { + logger.info("📭 복사할 플로우 없음"); + return flowIdMap; + } + + logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + + for (const originalFlowId of flowIds) { + try { + // 1) flow_definition 조회 + const flowDefResult = await client.query( + `SELECT * FROM flow_definition WHERE id = $1`, + [originalFlowId] + ); + + if (flowDefResult.rows.length === 0) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const flowDef = flowDefResult.rows[0]; + + // 2) flow_definition 복사 + const newFlowResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( + name, description, table_name, is_active, + company_code, created_by, db_source_type, db_connection_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + flowDef.name, + flowDef.description, + flowDef.table_name, + flowDef.is_active, + targetCompanyCode, // 새 회사 코드 + userId, + flowDef.db_source_type, + flowDef.db_connection_id, + ] + ); + + const newFlowId = newFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + + logger.info( + ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + + // 3) flow_step 복사 + const stepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, + [originalFlowId] + ); + + const stepIdMap = new Map(); + + for (const step of stepsResult.rows) { + const newStepResult = await client.query<{ id: number }>( + `INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, condition_json, + color, position_x, position_y, table_name, move_type, + status_column, status_value, target_table, field_mappings, + required_fields, integration_type, integration_config, display_config + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING id`, + [ + newFlowId, // 새 플로우 ID + step.step_name, + step.step_order, + step.condition_json, + step.color, + step.position_x, + step.position_y, + step.table_name, + step.move_type, + step.status_column, + step.status_value, + step.target_table, + step.field_mappings, + step.required_fields, + step.integration_type, + step.integration_config, + step.display_config, + ] + ); + + const newStepId = newStepResult.rows[0].id; + stepIdMap.set(step.id, newStepId); + } + + logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + + // 4) flow_step_connection 복사 (스텝 ID 재매핑) + const connectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, + [originalFlowId] + ); + + for (const conn of connectionsResult.rows) { + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + + if (!newFromStepId || !newToStepId) { + logger.warn( + `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` + ); + continue; + } + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ($1, $2, $3, $4)`, + [newFlowId, newFromStepId, newToStepId, conn.label] + ); + } + + logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); + } catch (error: any) { + logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); + throw error; + } + } + + logger.info(`✅ 플로우 복사 완료: ${flowIdMap.size}개`); + return flowIdMap; + } + + /** + * 화면 복사 + */ + private async copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, + userId: string, + client: PoolClient + ): Promise> { + const screenIdMap = new Map(); + + if (screenIds.size === 0) { + logger.info("📭 복사할 화면 없음"); + return screenIdMap; + } + + logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + + // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + const screenDefsToProcess: Array<{ + originalScreenId: number; + newScreenId: number; + screenDef: ScreenDefinition; + }> = []; + + for (const originalScreenId of screenIds) { + try { + // 1) screen_definitions 조회 + const screenDefResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1`, + [originalScreenId] + ); + + if (screenDefResult.rows.length === 0) { + logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); + continue; + } + + const screenDef = screenDefResult.rows[0]; + + // 2) 새 screen_code 생성 + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING screen_id`, + [ + screenDef.screen_name, + newScreenCode, // 새 화면 코드 + screenDef.table_name, + targetCompanyCode, // 새 회사 코드 + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, // deleted_date: NULL (새 화면은 삭제되지 않음) + null, // deleted_by: NULL + null, // delete_reason: NULL + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + // 저장해서 2단계에서 처리 + screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + } catch (error: any) { + logger.error( + `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + logger.info( + `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + ); + + for (const { + originalScreenId, + newScreenId, + screenDef, + } of screenDefsToProcess) { + try { + // screen_layouts 복사 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); + + // 1단계: component_id 매핑 생성 (원본 → 새 ID) + const componentIdMap = new Map(); + for (const layout of layoutsResult.rows) { + const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + componentIdMap.set(layout.component_id, newComponentId); + } + + // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; + + // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; + + // properties 내부 참조 업데이트 + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap + ); + + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + [ + newScreenId, // 새 화면 ID + layout.component_type, + newComponentId, // 새 컴포넌트 ID + newParentId, // 매핑된 parent_id + layout.position_x, + layout.position_y, + layout.width, + layout.height, + updatedProperties, // 업데이트된 속성 + layout.display_order, + layout.layout_type, + layout.layout_config, + layout.zones_config, + newZoneId, // 매핑된 zone_id + ] + ); + } + + logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + } catch (error: any) { + logger.error( + `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + return screenIdMap; + } + + /** + * 메뉴 위상 정렬 (부모 먼저) + */ + private topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + const menuMap = new Map(); + + for (const menu of menus) { + menuMap.set(menu.objid, menu); + } + + const visit = (menu: Menu) => { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menuMap.get(menu.parent_obj_id); + if (parent) { + visit(parent); + } + } + + visited.add(menu.objid); + result.push(menu); + }; + + for (const menu of menus) { + visit(menu); + } + + return result; + } + + /** + * screen_code 재매핑 + */ + private getNewScreenCode( + screenIdMap: Map, + screenCode: string | null, + client: PoolClient + ): string | null { + if (!screenCode) return null; + + // screen_code로 screen_id 조회 (원본 회사) + // 간단하게 처리: 새 화면 코드는 이미 생성됨 + return screenCode; + } + + /** + * 메뉴 복사 + */ + private async copyMenus( + menus: Menu[], + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient + ): Promise> { + const menuIdMap = new Map(); + + if (menus.length === 0) { + logger.info("📭 복사할 메뉴 없음"); + return menuIdMap; + } + + logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + + // 위상 정렬 (부모 먼저 삽입) + const sortedMenus = this.topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + try { + // 새 objid 생성 + const newObjId = await this.getNextMenuObjid(client); + + // parent_obj_id 재매핑 + // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + let newParentObjId: number | null; + if (!menu.parent_obj_id || menu.parent_obj_id === 0) { + newParentObjId = 0; // 최상위 메뉴는 항상 0 + } else { + newParentObjId = + menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + } + + // source_menu_objid 저장: 최상위 메뉴는 원본 ID, 하위 메뉴는 최상위의 원본 ID + const sourceMenuObjid = + !menu.parent_obj_id || menu.parent_obj_id === 0 + ? menu.objid // 최상위 메뉴: 자신의 ID가 원본 + : null; // 하위 메뉴: NULL (최상위만 추적) + + if (sourceMenuObjid) { + logger.info( + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (최상위 메뉴)` + ); + } + + // screen_code는 그대로 유지 (화면-메뉴 할당에서 처리) + await client.query( + `INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, status, system_name, + company_code, lang_key, lang_key_desc, screen_code, menu_code, + source_menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [ + newObjId, + menu.menu_type, + newParentObjId, // 재매핑 + menu.menu_name_kor, + menu.menu_name_eng, + menu.seq, + menu.menu_url, + menu.menu_desc, + userId, + menu.status, + menu.system_name, + targetCompanyCode, // 새 회사 코드 + menu.lang_key, + menu.lang_key_desc, + menu.screen_code, // 그대로 유지 + menu.menu_code, + sourceMenuObjid, // 원본 메뉴 ID (최상위만) + ] + ); + + menuIdMap.set(menu.objid, newObjId); + + logger.info( + ` ✅ 메뉴 복사: ${menu.objid} → ${newObjId} (${menu.menu_name_kor})` + ); + } catch (error: any) { + logger.error(`❌ 메뉴 복사 실패: objid=${menu.objid}`, error); + throw error; + } + } + + logger.info(`✅ 메뉴 복사 완료: ${menuIdMap.size}개`); + return menuIdMap; + } + + /** + * 화면-메뉴 할당 + */ + private async createScreenMenuAssignments( + menus: Menu[], + menuIdMap: Map, + screenIdMap: Map, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info(`🔗 화면-메뉴 할당 중...`); + + let assignmentCount = 0; + + for (const menu of menus) { + const newMenuObjid = menuIdMap.get(menu.objid); + if (!newMenuObjid) continue; + + // 원본 메뉴에 할당된 화면 조회 + const assignmentsResult = await client.query<{ + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT screen_id, display_order, is_active + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menu.objid, menu.company_code] + ); + + for (const assignment of assignmentsResult.rows) { + const newScreenId = screenIdMap.get(assignment.screen_id); + if (!newScreenId) { + logger.warn( + `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` + ); + continue; + } + + // 새 할당 생성 + await client.query( + `INSERT INTO screen_menu_assignments ( + screen_id, menu_objid, company_code, display_order, is_active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + newScreenId, // 재매핑 + newMenuObjid, // 재매핑 + targetCompanyCode, + assignment.display_order, + assignment.is_active, + "system", + ] + ); + + assignmentCount++; + } + } + + logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + } + + /** + * 코드 카테고리 중복 체크 + */ + private async checkCodeCategoryExists( + categoryCode: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_category + WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 + ) as exists`, + [categoryCode, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 정보 중복 체크 + */ + private async checkCodeInfoExists( + categoryCode: string, + codeValue: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_info + WHERE code_category = $1 AND code_value = $2 + AND company_code = $3 AND menu_objid = $4 + ) as exists`, + [categoryCode, codeValue, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 복사 + */ + private async copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 코드 복사 중...`); + + let categoryCount = 0; + let codeCount = 0; + let skippedCategories = 0; + let skippedCodes = 0; + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCategories++; + logger.debug( + ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + categoryCount++; + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCodes++; + logger.debug( + ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 코드 복사 + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + code.code_category, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + codeCount++; + } + + logger.info( + `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` + ); + } +} diff --git a/db/migrations/README_1003.md b/db/migrations/README_1003.md new file mode 100644 index 00000000..629e2cb8 --- /dev/null +++ b/db/migrations/README_1003.md @@ -0,0 +1,184 @@ +# 마이그레이션 1003: source_menu_objid 추가 + +## 📋 개요 + +메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다. + +## 🎯 목적 + +### 이전 방식의 문제점 +- 메뉴 이름으로만 기존 복사본 판단 +- 같은 이름의 다른 메뉴도 삭제될 위험 +- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가 + +### 개선 후 +- 원본 메뉴 ID로 정확히 추적 +- 같은 원본에서 복사된 메뉴만 덮어쓰기 +- 수동 메뉴와 복사 메뉴 명확히 구분 + +## 🗄️ 스키마 변경 + +### 추가되는 컬럼 +```sql +ALTER TABLE menu_info +ADD COLUMN source_menu_objid BIGINT; +``` + +### 인덱스 +```sql +-- 단일 인덱스 +CREATE INDEX idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스 (회사별 검색 최적화) +CREATE INDEX idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); +``` + +## 📊 데이터 구조 + +### 복사된 메뉴의 source_menu_objid 값 + +| 메뉴 레벨 | source_menu_objid | 설명 | +|-----------|-------------------|------| +| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 | +| 하위 메뉴 | NULL | 최상위 메뉴만 추적 | +| 수동 생성 메뉴 | NULL | 복사가 아님 | + +### 예시 + +#### 원본 (COMPANY_7) +``` +- 사용자 (objid: 1762407678882) + └─ 영업관리 (objid: 1762421877772) + └─ 거래처관리 (objid: 1762421920304) +``` + +#### 복사본 (COMPANY_11) +``` +- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적 + └─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL) + └─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL) +``` + +## 🚀 실행 방법 + +### 로컬 PostgreSQL +```bash +psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### Docker 환경 +```bash +# 백엔드 컨테이너를 통해 실행 +docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### DBeaver / pgAdmin +1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기 +2. 전체 스크립트 실행 + +## ✅ 확인 방법 + +### 1. 컬럼 추가 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 생성 확인 +```sql +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname | indexdef +---------------------------------|---------------------------------- +idx_menu_info_source_menu_objid | CREATE INDEX ... +idx_menu_info_source_company | CREATE INDEX ... +``` + +### 3. 기존 데이터 확인 +```sql +-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터) +SELECT + COUNT(*) as total, + COUNT(source_menu_objid) as with_source +FROM menu_info; +``` + +**예상 결과**: +``` +total | with_source +------|------------- + 114 | 0 +``` + +## 🔄 롤백 (필요 시) + +```sql +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_menu_info_source_menu_objid; +DROP INDEX IF EXISTS idx_menu_info_source_company; + +-- 컬럼 삭제 +ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid; +``` + +## 📝 주의사항 + +1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다. +2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다. +3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다. + +## 🧪 테스트 시나리오 + +### 1. 첫 복사 (source_menu_objid 설정) +``` +원본: 사용자 (objid: 1762407678882, COMPANY_7) +복사: 사용자 (objid: 1763688215729, COMPANY_11) + source_menu_objid: 1762407678882 ✅ +``` + +### 2. 재복사 (정확한 덮어쓰기) +``` +복사 전 조회: + SELECT objid FROM menu_info + WHERE source_menu_objid = 1762407678882 + AND company_code = 'COMPANY_11' + → 1763688215729 발견 + +동작: + 1. objid=1763688215729의 메뉴 트리 전체 삭제 + 2. 새로 복사 (source_menu_objid: 1762407678882) +``` + +### 3. 다른 메뉴는 영향 없음 +``` +수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11) + source_menu_objid: NULL ✅ + +"사용자" 메뉴 재복사 시: + → 관리자 메뉴는 그대로 유지 ✅ +``` + +## 📚 관련 파일 + +- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` +- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts` + - `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기 + - `copyMenus()`: 복사 시 source_menu_objid 저장 + diff --git a/db/migrations/RUN_MIGRATION_1003.md b/db/migrations/RUN_MIGRATION_1003.md new file mode 100644 index 00000000..6b33bafd --- /dev/null +++ b/db/migrations/RUN_MIGRATION_1003.md @@ -0,0 +1,146 @@ +# 마이그레이션 1003 실행 가이드 + +## ❌ 현재 에러 +``` +column "source_menu_objid" does not exist +``` + +**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음 + +## ✅ 해결 방법 + +### 방법 1: psql 직접 실행 (권장) + +```bash +# 1. PostgreSQL 접속 정보 확인 +# - Host: localhost (또는 실제 DB 호스트) +# - Port: 5432 (기본값) +# - Database: ilshin +# - User: postgres + +# 2. 마이그레이션 실행 +cd /Users/kimjuseok/ERP-node +psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql + +# 또는 대화형으로 +psql -h localhost -U postgres -d ilshin +# 그 다음 파일 내용 붙여넣기 +``` + +### 방법 2: DBeaver / pgAdmin에서 실행 + +1. DBeaver 또는 pgAdmin 실행 +2. `ilshin` 데이터베이스 연결 +3. SQL 편집기 열기 +4. 아래 SQL 복사하여 실행: + +```sql +-- source_menu_objid 컬럼 추가 +ALTER TABLE menu_info +ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; + +-- 인덱스 생성 (검색 성능 향상) +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스: 회사별 원본 메뉴 검색 +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); + +-- 컬럼 설명 추가 +COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)'; + +-- 확인 +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +### 방법 3: Docker를 통한 실행 + +Docker Compose 설정 확인 후: + +```bash +# Docker Compose에 DB 서비스가 있는 경우 +docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql + +# 또는 SQL을 직접 실행 +docker-compose exec db psql -U postgres -d ilshin -c " +ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid); +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code); +" +``` + +## ✅ 실행 후 확인 + +### 1. 컬럼이 추가되었는지 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 확인 +```sql +SELECT indexname +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname +--------------------------------- +idx_menu_info_source_menu_objid +idx_menu_info_source_company +``` + +### 3. 메뉴 복사 재시도 +마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요. + +## 🔍 DB 접속 정보 찾기 + +### 환경 변수 확인 +```bash +# .env 파일 확인 +cat backend-node/.env | grep DB + +# Docker Compose 확인 +cat docker-compose*.yml | grep -A 10 postgres +``` + +### 일반적인 접속 정보 +- **Host**: localhost 또는 127.0.0.1 +- **Port**: 5432 (기본값) +- **Database**: ilshin +- **User**: postgres +- **Password**: (환경 설정 파일에서 확인) + +## ⚠️ 주의사항 + +1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장 +2. **권한 확인**: ALTER TABLE 권한이 필요합니다 +3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다 + +## 📞 문제 해결 + +### "permission denied" 에러 +→ postgres 사용자 또는 superuser 권한으로 실행 필요 + +### "relation does not exist" 에러 +→ 올바른 데이터베이스(ilshin)에 접속했는지 확인 + +### "already exists" 에러 +→ 이미 실행됨. 무시하고 진행 가능 + diff --git a/db/scripts/README_cleanup.md b/db/scripts/README_cleanup.md new file mode 100644 index 00000000..ecd7879f --- /dev/null +++ b/db/scripts/README_cleanup.md @@ -0,0 +1,126 @@ +# COMPANY_11 테스트 데이터 정리 가이드 + +## 📋 개요 + +메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다. + +## ⚠️ 중요 사항 + +- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`) +- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드 +- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능 +- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT + +## 🚀 실행 방법 + +### 방법 1: Docker 컨테이너에서 직접 실행 (권장) + +```bash +# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨) +cd /Users/kimjuseok/ERP-node +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql + +# 2. 실제 삭제 실행 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +### 방법 2: DBeaver 또는 pgAdmin에서 실행 + +1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기 +2. 전체 스크립트 실행 (롤백되어 안전) +3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행 + +### 방법 3: psql 직접 접속 + +```bash +# 1. 컨테이너 접속 +docker exec -it erp-node-db-1 psql -U postgres -d ilshin + +# 2. SQL 복사 붙여넣기 +# (cleanup_company_11_execute.sql 내용 복사) +``` + +## 📊 삭제 대상 + +| 항목 | 테이블명 | 삭제 여부 | +|------|----------|-----------| +| 메뉴 | `menu_info` | ✅ 삭제 | +| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 | +| 화면 정의 | `screen_definitions` | ✅ 삭제 | +| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 | +| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 | +| 플로우 정의 | `flow_definition` | ✅ 삭제 | +| 플로우 스텝 | `flow_step` | ✅ 삭제 | +| 플로우 연결 | `flow_step_connection` | ✅ 삭제 | +| 코드 카테고리 | `code_category` | ✅ 삭제 | +| 코드 정보 | `code_info` | ✅ 삭제 | +| **권한 그룹** | `authority_master` | ❌ **보존** | +| **권한 멤버** | `authority_sub_user` | ❌ **보존** | +| **사용자** | `user_info` | ❌ **보존** | + +## 🔍 삭제 순서 (외래키 제약 고려) + +``` +1. screen_layouts (화면 레이아웃) +2. screen_menu_assignments (화면-메뉴 할당) +3. screen_definitions (화면 정의) +4. rel_menu_auth (메뉴 권한) +5. menu_info (메뉴) +6. flow_step (플로우 스텝) +7. flow_step_connection (플로우 연결) +8. flow_definition (플로우 정의) +9. code_info (코드 정보) +10. code_category (코드 카테고리) +``` + +## ✅ 실행 후 확인 + +스크립트 실행 후 다음과 같이 표시됩니다: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ 삭제 완료! + +남은 데이터: + - 메뉴: 0 개 + - 화면: 0 개 + - 권한 그룹: 1 개 (보존됨) + - 사용자: 1 개 (보존됨) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✨ 정리 완료! 메뉴 복사 테스트 준비됨 +``` + +## 🧪 테스트 시나리오 + +1. **데이터 정리** + ```bash + docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql + ``` + +2. **메뉴 복사 실행** + - 프론트엔드에서 원본 메뉴 선택 + - "복사" 버튼 클릭 + - 대상 회사: COMPANY_11 선택 + - 복사 실행 + +3. **복사 결과 확인** + - COMPANY_11 사용자(copy)로 로그인 + - 사용자 메뉴에 복사된 메뉴 표시 확인 + - 버튼 클릭 시 모달 화면 정상 열림 확인 + - 플로우 기능 정상 작동 확인 + +## 🔄 재테스트 + +재테스트가 필요하면 다시 정리 스크립트를 실행하세요: + +```bash +# 빠른 재테스트 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +## 📝 참고 + +- **백업**: 중요한 데이터가 있다면 먼저 백업하세요 +- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다 +- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다 + diff --git a/docs/메뉴_복사_기능_구현_계획서.md b/docs/메뉴_복사_기능_구현_계획서.md new file mode 100644 index 00000000..a53a5704 --- /dev/null +++ b/docs/메뉴_복사_기능_구현_계획서.md @@ -0,0 +1,1660 @@ +# 메뉴 복사 기능 구현 계획서 + +## 📋 목차 +1. [개요](#개요) +2. [요구사항](#요구사항) +3. [데이터베이스 구조 분석](#데이터베이스-구조-분석) +4. [복사 대상 항목](#복사-대상-항목) +5. [복사 알고리즘](#복사-알고리즘) +6. [구현 단계](#구현-단계) +7. [API 명세](#api-명세) +8. [UI/UX 설계](#uiux-설계) +9. [예외 처리](#예외-처리) +10. [테스트 계획](#테스트-계획) + +--- + +## 개요 + +### 목적 +메뉴관리 화면에서 **복사 버튼 하나**로 선택된 메뉴와 관련된 모든 리소스를 다른 회사로 복사하여, 복사 즉시 해당 회사에서 사용 가능하도록 합니다. + +### 핵심 기능 +- 메뉴 트리 구조 복사 (부모-자식 관계 유지) +- 화면 + 레이아웃 복사 (모달, 조건부 컨테이너 포함) +- 플로우 제어 복사 (스텝, 연결, 조건) +- 코드 카테고리 + 코드 정보 복사 +- 중복 화면 자동 제거 +- 참조 관계 자동 재매핑 +- company_code 자동 변경 + +--- + +## 요구사항 + +### 기능 요구사항 + +#### FR-1: 메뉴 복사 +- **설명**: 선택된 메뉴와 하위 메뉴를 모두 복사 +- **입력**: 원본 메뉴 objid, 대상 회사 company_code +- **출력**: 복사된 메뉴 목록 +- **제약**: 메뉴 계층 구조 유지 + +#### FR-2: 화면 복사 +- **설명**: 메뉴에 할당된 모든 화면 복사 +- **입력**: 메뉴 objid 목록 +- **출력**: 복사된 화면 목록 +- **제약**: 중복 화면은 하나만 복사 + +#### FR-3: 화면 내부 참조 추적 +- **설명**: 화면 레이아웃에서 참조되는 화면들을 재귀적으로 추적 +- **대상**: + - 모달 버튼의 targetScreenId + - 조건부 컨테이너의 sections[].screenId + - 모달 안의 모달 (중첩 구조) +- **제약**: 무한 루프 방지 (이미 방문한 화면 체크) + +#### FR-4: 플로우 복사 +- **설명**: 화면에서 참조되는 플로우를 모두 복사 +- **대상**: + - flow_definition (플로우 정의) + - flow_step (스텝) + - flow_step_connection (스텝 간 연결) +- **제약**: 스텝 ID 재매핑 + +#### FR-5: 코드 복사 +- **설명**: 메뉴에 연결된 코드 카테고리와 코드 복사 +- **대상**: + - code_category (menu_objid 기준) + - code_info (menu_objid 기준) +- **제약**: 중복 카테고리 병합 + +#### FR-6: 참조 ID 재매핑 +- **설명**: 복사된 리소스의 ID를 원본 ID에서 새 ID로 자동 변경 +- **대상**: + - screen_id (화면 ID) + - flow_id (플로우 ID) + - menu_objid (메뉴 ID) + - step_id (스텝 ID) +- **방법**: ID 매핑 테이블 사용 + +### 비기능 요구사항 + +#### NFR-1: 성능 +- 복사 시간: 메뉴 100개 기준 2분 이내 +- 트랜잭션: 전체 작업을 하나의 트랜잭션으로 처리 + +#### NFR-2: 신뢰성 +- 실패 시 롤백: 일부만 복사되는 것 방지 +- 중복 실행 방지: 같은 요청 중복 처리 방지 + +#### NFR-3: 사용성 +- 진행 상황 표시: 실시간 복사 진행률 표시 +- 결과 보고서: 복사된 항목 상세 리스트 + +--- + +## 데이터베이스 구조 분석 + +### 주요 테이블 및 관계 + +```sql +-- 1. 메뉴 (계층 구조) +menu_info + ├─ objid (PK) - 메뉴 고유 ID + ├─ parent_obj_id - 부모 메뉴 ID + ├─ company_code (FK) - 회사 코드 + └─ screen_code - 할당된 화면 코드 + +-- 2. 화면 정의 +screen_definitions + ├─ screen_id (PK) - 화면 고유 ID + ├─ screen_code (UNIQUE) - 화면 코드 + ├─ company_code (FK) - 회사 코드 + ├─ table_name - 연결된 테이블 + └─ layout_metadata (JSONB) - 레이아웃 메타데이터 + +-- 3. 화면 레이아웃 (컴포넌트) +screen_layouts + ├─ layout_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ component_type - 컴포넌트 타입 + ├─ properties (JSONB) - 컴포넌트 속성 + │ ├─ componentConfig.action.targetScreenId (모달 참조) + │ ├─ sections[].screenId (조건부 컨테이너) + │ └─ dataflowConfig.flowConfig.flowId (플로우 참조) + └─ parent_id - 부모 컴포넌트 ID + +-- 4. 화면-메뉴 할당 +screen_menu_assignments + ├─ assignment_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ menu_objid (FK) - 메뉴 ID + └─ company_code (FK) - 회사 코드 + +-- 5. 플로우 정의 +flow_definition + ├─ id (PK) - 플로우 ID + ├─ name - 플로우 이름 + ├─ table_name - 연결된 테이블 + └─ company_code (FK) - 회사 코드 + +-- 6. 플로우 스텝 +flow_step + ├─ id (PK) - 스텝 ID + ├─ flow_definition_id (FK) - 플로우 ID + ├─ step_name - 스텝 이름 + ├─ step_order - 순서 + ├─ condition_json (JSONB) - 조건 + └─ integration_config (JSONB) - 통합 설정 + +-- 7. 플로우 스텝 연결 +flow_step_connection + ├─ id (PK) + ├─ flow_definition_id (FK) - 플로우 ID + ├─ from_step_id (FK) - 출발 스텝 ID + ├─ to_step_id (FK) - 도착 스텝 ID + └─ label - 연결 라벨 + +-- 8. 코드 카테고리 +code_category + ├─ category_code (PK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) - 메뉴 ID + ├─ category_name - 카테고리 이름 + └─ description - 설명 + +-- 9. 코드 정보 +code_info + ├─ code_category (PK, FK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) + ├─ code_value (PK) - 코드 값 + ├─ code_name - 코드 이름 + └─ description - 설명 +``` + +### 외래키 제약조건 + +```sql +-- 중요: 삽입 순서 고려 필요 +1. company_mng (회사 정보) - 먼저 존재해야 함 +2. menu_info (메뉴) +3. screen_definitions (화면) +4. flow_definition (플로우) +5. screen_layouts (레이아웃) +6. screen_menu_assignments (화면-메뉴 할당) +7. flow_step (플로우 스텝) +8. flow_step_connection (스텝 연결) +9. code_category (코드 카테고리) +10. code_info (코드 정보) +``` + +--- + +## 복사 대상 항목 + +### 1단계: 메뉴 트리 수집 + +```typescript +// 재귀적으로 하위 메뉴 수집 +function collectMenuTree(rootMenuObjid: number): Menu[] { + const result: Menu[] = []; + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + const menu = getMenuByObjid(currentObjid); + result.push(menu); + + // 자식 메뉴들을 스택에 추가 + const children = getChildMenus(currentObjid); + stack.push(...children.map(c => c.objid)); + } + + return result; +} +``` + +**수집 항목**: +- 원본 메뉴 objid +- 하위 메뉴 objid 목록 (재귀) +- 부모-자식 관계 매핑 + +### 2단계: 화면 수집 (중복 제거) + +```typescript +// 메뉴에 할당된 화면 + 참조 화면 수집 +function collectScreens(menuObjids: number[]): Set { + const screenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignments = getScreenMenuAssignments(menuObjid); + assignments.forEach(a => screenIds.add(a.screen_id)); + } + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + while (queue.length > 0) { + const screenId = queue.shift()!; + if (visited.has(screenId)) continue; + visited.add(screenId); + + const referencedScreens = extractReferencedScreens(screenId); + referencedScreens.forEach(refId => { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + }); + } + + return screenIds; +} + +// 화면 레이아웃에서 참조 화면 추출 +function extractReferencedScreens(screenId: number): number[] { + const layouts = getScreenLayouts(screenId); + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + // 모달 버튼 + if (props?.componentConfig?.action?.targetScreenId) { + referenced.push(props.componentConfig.action.targetScreenId); + } + + // 조건부 컨테이너 + if (props?.sections) { + for (const section of props.sections) { + if (section.screenId) { + referenced.push(section.screenId); + } + } + } + } + + return referenced; +} +``` + +**수집 항목**: +- 직접 할당 화면 ID 목록 +- 모달 참조 화면 ID 목록 +- 조건부 컨테이너 내 화면 ID 목록 +- 중복 제거된 최종 화면 ID Set + +### 3단계: 플로우 수집 + +```typescript +// 화면에서 참조되는 플로우 수집 +function collectFlows(screenIds: Set): Set { + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layouts = getScreenLayouts(screenId); + + for (const layout of layouts) { + const flowId = layout.properties?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + return flowIds; +} +``` + +**수집 항목**: +- flow_definition.id 목록 +- 각 플로우의 flow_step 목록 +- 각 플로우의 flow_step_connection 목록 + +### 4단계: 코드 수집 + +```typescript +// 메뉴에 연결된 코드 수집 +function collectCodes(menuObjids: number[], companyCode: string): { + categories: CodeCategory[]; + codes: CodeInfo[]; +} { + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const cats = getCodeCategories(menuObjid, companyCode); + categories.push(...cats); + + // 각 카테고리의 코드 정보 + for (const cat of cats) { + const infos = getCodeInfos(cat.category_code, menuObjid, companyCode); + codes.push(...infos); + } + } + + return { categories, codes }; +} +``` + +**수집 항목**: +- code_category 목록 (menu_objid 기준) +- code_info 목록 (menu_objid + category_code 기준) + +--- + +## 복사 알고리즘 + +### 전체 프로세스 + +```typescript +async function copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string +): Promise { + + // 트랜잭션 시작 + const client = await pool.connect(); + await client.query('BEGIN'); + + try { + // 1단계: 수집 (Collection Phase) + const menus = collectMenuTree(sourceMenuObjid); + const screenIds = collectScreens(menus.map(m => m.objid)); + const flowIds = collectFlows(screenIds); + const codes = collectCodes(menus.map(m => m.objid), menus[0].company_code); + + // 2단계: 플로우 복사 (Flow Copy Phase) + const flowIdMap = await copyFlows(flowIds, targetCompanyCode, userId, client); + + // 3단계: 화면 복사 (Screen Copy Phase) + const screenIdMap = await copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, // 플로우 ID 재매핑 + userId, + client + ); + + // 4단계: 메뉴 복사 (Menu Copy Phase) + const menuIdMap = await copyMenus( + menus, + targetCompanyCode, + screenIdMap, // 화면 ID 재매핑 + userId, + client + ); + + // 5단계: 화면-메뉴 할당 (Assignment Phase) + await createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // 6단계: 코드 복사 (Code Copy Phase) + await copyCodes( + codes, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // 커밋 + await client.query('COMMIT'); + + return { + success: true, + copiedMenus: Object.values(menuIdMap).length, + copiedScreens: Object.values(screenIdMap).length, + copiedFlows: Object.values(flowIdMap).length, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + }; + + } catch (error) { + // 롤백 + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} +``` + +### 플로우 복사 알고리즘 + +```typescript +async function copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise> { + + const flowIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalFlowId of flowIds) { + // 1) flow_definition 복사 + const flowDef = await getFlowDefinition(originalFlowId, client); + const newFlowId = await insertFlowDefinition({ + ...flowDef, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + flowIdMap.set(originalFlowId, newFlowId); + + // 2) flow_step 복사 + const steps = await getFlowSteps(originalFlowId, client); + const stepIdMap = new Map(); // 스텝 ID 매핑 + + for (const step of steps) { + const newStepId = await insertFlowStep({ + ...step, + flow_definition_id: newFlowId, // 새 플로우 ID + }, client); + + stepIdMap.set(step.id, newStepId); + } + + // 3) flow_step_connection 복사 (스텝 ID 재매핑) + const connections = await getFlowStepConnections(originalFlowId, client); + + for (const conn of connections) { + await insertFlowStepConnection({ + flow_definition_id: newFlowId, + from_step_id: stepIdMap.get(conn.from_step_id)!, // 재매핑 + to_step_id: stepIdMap.get(conn.to_step_id)!, // 재매핑 + label: conn.label, + }, client); + } + } + + return flowIdMap; +} +``` + +### 화면 복사 알고리즘 + +```typescript +async function copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, // 플로우 ID 재매핑 + userId: string, + client: PoolClient +): Promise> { + + const screenIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalScreenId of screenIds) { + // 1) screen_definitions 복사 + const screenDef = await getScreenDefinition(originalScreenId, client); + + // 새 screen_code 생성 (중복 방지) + const newScreenCode = await generateUniqueScreenCode(targetCompanyCode, client); + + const newScreenId = await insertScreenDefinition({ + ...screenDef, + screen_code: newScreenCode, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + screenIdMap.set(originalScreenId, newScreenId); + + // 2) screen_layouts 복사 + const layouts = await getScreenLayouts(originalScreenId, client); + + for (const layout of layouts) { + // properties 내부 참조 업데이트 + const updatedProperties = updateReferencesInProperties( + layout.properties, + screenIdMap, // 화면 ID 재매핑 + flowIdMap // 플로우 ID 재매핑 + ); + + await insertScreenLayout({ + screen_id: newScreenId, // 새 화면 ID + component_type: layout.component_type, + component_id: layout.component_id, + parent_id: layout.parent_id, + position_x: layout.position_x, + position_y: layout.position_y, + width: layout.width, + height: layout.height, + properties: updatedProperties, // 업데이트된 속성 + display_order: layout.display_order, + layout_type: layout.layout_type, + layout_config: layout.layout_config, + zones_config: layout.zones_config, + zone_id: layout.zone_id, + }, client); + } + } + + return screenIdMap; +} + +// properties 내부 참조 업데이트 +function updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map +): any { + + if (!properties) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); // 깊은 복사 + + // 1) 모달 버튼의 targetScreenId + if (updated?.componentConfig?.action?.targetScreenId) { + const oldId = updated.componentConfig.action.targetScreenId; + const newId = screenIdMap.get(oldId); + if (newId) { + updated.componentConfig.action.targetScreenId = newId; + } + } + + // 2) 조건부 컨테이너의 sections[].screenId + if (updated?.sections) { + for (const section of updated.sections) { + if (section.screenId) { + const oldId = section.screenId; + const newId = screenIdMap.get(oldId); + if (newId) { + section.screenId = newId; + } + } + } + } + + // 3) 플로우 제어의 flowId + if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { + const oldId = updated.webTypeConfig.dataflowConfig.flowConfig.flowId; + const newId = flowIdMap.get(oldId); + if (newId) { + updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; + } + } + + return updated; +} +``` + +### 메뉴 복사 알고리즘 + +```typescript +async function copyMenus( + menus: Menu[], + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient +): Promise> { + + const menuIdMap = new Map(); // 원본 objid → 새 objid + + // 1) 메뉴를 깊이 순으로 정렬 (부모 먼저 삽입) + const sortedMenus = topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + // screen_code 업데이트 (화면 ID 재매핑) + const newScreenCode = menu.screen_code + ? getNewScreenCode(screenIdMap, menu.screen_code) + : null; + + // parent_obj_id 업데이트 (메뉴 ID 재매핑) + const newParentObjId = menu.parent_obj_id + ? menuIdMap.get(menu.parent_obj_id) || null + : null; + + // 새 objid 생성 + const newObjId = await getNextMenuObjid(client); + + await insertMenu({ + objid: newObjId, + menu_type: menu.menu_type, + parent_obj_id: newParentObjId, // 재매핑 + menu_name_kor: menu.menu_name_kor, + menu_name_eng: menu.menu_name_eng, + seq: menu.seq, + menu_url: menu.menu_url, + menu_desc: menu.menu_desc, + writer: userId, + status: menu.status, + system_name: menu.system_name, + company_code: targetCompanyCode, // 새 회사 코드 + lang_key: menu.lang_key, + lang_key_desc: menu.lang_key_desc, + screen_code: newScreenCode, // 재매핑 + menu_code: menu.menu_code, + }, client); + + menuIdMap.set(menu.objid, newObjId); + } + + return menuIdMap; +} + +// 위상 정렬 (부모 먼저) +function topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + + function visit(menu: Menu) { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menus.find(m => m.objid === menu.parent_obj_id); + if (parent) visit(parent); + } + + visited.add(menu.objid); + result.push(menu); + } + + for (const menu of menus) { + visit(menu); + } + + return result; +} +``` + +### 코드 복사 알고리즘 + +```typescript +async function copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise { + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 category_code + company_code + menu_objid + const exists = await checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeCategory({ + category_code: category.category_code, + category_name: category.category_name, + category_name_eng: category.category_name_eng, + description: category.description, + sort_order: category.sort_order, + is_active: category.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 code_category + code_value + company_code + menu_objid + const exists = await checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeInfo({ + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + description: code.description, + sort_order: code.sort_order, + is_active: code.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } +} +``` + +--- + +## 구현 단계 + +### Phase 1: 백엔드 서비스 구현 + +**파일**: `backend-node/src/services/menuCopyService.ts` + +#### 1.1 데이터 수집 함수 +- `collectMenuTree()` - 메뉴 트리 수집 +- `collectScreens()` - 화면 수집 (중복 제거) +- `collectFlows()` - 플로우 수집 +- `collectCodes()` - 코드 수집 +- `extractReferencedScreens()` - 화면 참조 추출 + +#### 1.2 복사 함수 +- `copyFlows()` - 플로우 복사 +- `copyScreens()` - 화면 복사 +- `copyMenus()` - 메뉴 복사 +- `copyCodes()` - 코드 복사 +- `createScreenMenuAssignments()` - 화면-메뉴 할당 + +#### 1.3 유틸리티 함수 +- `updateReferencesInProperties()` - JSONB 내부 참조 업데이트 +- `topologicalSortMenus()` - 메뉴 위상 정렬 +- `generateUniqueScreenCode()` - 고유 화면 코드 생성 +- `getNextMenuObjid()` - 다음 메뉴 objid + +### Phase 2: 백엔드 컨트롤러 구현 + +**파일**: `backend-node/src/controllers/menuController.ts` + +```typescript +// POST /api/admin/menus/:menuObjid/copy +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + + // 권한 체크 + if (req.user!.companyCode !== "*") { + // 최고 관리자만 가능 + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자만 가능합니다", + }); + return; + } + + // 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid), + targetCompanyCode, + userId + ); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + + } catch (error: any) { + logger.error("메뉴 복사 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: error.message, + }); + } +} +``` + +### Phase 3: 백엔드 라우터 등록 + +**파일**: `backend-node/src/routes/admin.ts` + +```typescript +// 메뉴 복사 API +router.post( + "/menus/:menuObjid/copy", + authenticate, + copyMenu +); +``` + +### Phase 4: 프론트엔드 API 클라이언트 + +**파일**: `frontend/lib/api/menu.ts` + +```typescript +/** + * 메뉴 복사 + */ +export async function copyMenu( + menuObjid: number, + targetCompanyCode: string +): Promise> { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { targetCompanyCode } + ); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} + +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + warnings?: string[]; +} +``` + +### Phase 5: 프론트엔드 UI 구현 + +**파일**: `frontend/components/admin/MenuCopyDialog.tsx` + +```typescript +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + } + }, [open]); + + const handleCopy = async () => { + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + const response = await copyMenu(menuObjid, targetCompanyCode); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + } else { + toast.error(response.error || "메뉴 복사 실패"); + } + + setCopying(false); + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} +
+ + +
+ + {/* 복사 항목 안내 */} +
+

복사되는 항목:

+
    +
  • 메뉴 구조 (하위 메뉴 포함)
  • +
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • +
  • 플로우 제어 (스텝, 연결)
  • +
  • 코드 카테고리 + 코드
  • +
+
+ + {/* 복사 결과 */} + {result && ( +
+

복사 완료!

+
    +
  • 메뉴: {result.copiedMenus}개
  • +
  • 화면: {result.copiedScreens}개
  • +
  • 플로우: {result.copiedFlows}개
  • +
  • 코드 카테고리: {result.copiedCategories}개
  • +
  • 코드: {result.copiedCodes}개
  • +
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} +``` + +### Phase 6: 메뉴 관리 화면 통합 + +**파일**: `frontend/components/admin/MenuManagement.tsx` + +```typescript +// 복사 버튼 추가 + + +// 다이얼로그 + +``` + +--- + +## API 명세 + +### POST /api/admin/menus/:menuObjid/copy + +**설명**: 메뉴와 관련된 모든 리소스를 다른 회사로 복사 + +**권한**: 최고 관리자 전용 (company_code = "*") + +**요청**: +```typescript +POST /api/admin/menus/100/copy +Content-Type: application/json + +{ + "targetCompanyCode": "COMPANY_B" +} +``` + +**응답 (성공)**: +```typescript +{ + "success": true, + "message": "메뉴 복사 완료", + "data": { + "copiedMenus": 5, + "copiedScreens": 12, + "copiedFlows": 3, + "copiedCategories": 8, + "copiedCodes": 45, + "menuIdMap": { + "100": 200, + "101": 201, + "102": 202 + }, + "screenIdMap": { + "10": 30, + "11": 31, + "12": 32 + }, + "flowIdMap": { + "5": 10, + "6": 11 + }, + "warnings": [ + "item_info 테이블에 데이터를 추가해야 합니다", + "메뉴 권한 설정이 필요합니다" + ] + } +} +``` + +**응답 (실패)**: +```typescript +{ + "success": false, + "message": "메뉴 복사 중 오류가 발생했습니다", + "error": "대상 회사가 존재하지 않습니다" +} +``` + +**에러 코드**: +- `403`: 권한 없음 (최고 관리자 아님) +- `404`: 메뉴를 찾을 수 없음 +- `400`: 잘못된 요청 (대상 회사 코드 누락) +- `500`: 서버 내부 오류 + +--- + +## UI/UX 설계 + +### 1. 메뉴 관리 화면 + +``` +┌─────────────────────────────────────────────┐ +│ 메뉴 관리 │ +├─────────────────────────────────────────────┤ +│ ┌─ 영업관리 (objid: 100) │ +│ │ ├─ [편집] [삭제] [복사] ← 복사 버튼 │ +│ │ ├─ 수주관리 (objid: 101) │ +│ │ │ └─ [편집] [삭제] [복사] │ +│ │ └─ 견적관리 (objid: 102) │ +│ │ └─ [편집] [삭제] [복사] │ +│ └─ ... │ +└─────────────────────────────────────────────┘ +``` + +### 2. 복사 다이얼로그 + +#### 초기 상태 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사 * │ +│ [회사 선택 ▼] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 복사되는 항목: │ │ +│ │ • 메뉴 구조 (하위 메뉴 포함) │ │ +│ │ • 화면 + 레이아웃 │ │ +│ │ • 플로우 제어 │ │ +│ │ • 코드 카테고리 + 코드 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소] [복사 시작] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 중 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ⚙️ 복사 진행 중... │ │ +│ │ │ │ +│ │ ✅ 메뉴 수집 완료 │ │ +│ │ ✅ 화면 수집 완료 │ │ +│ │ ⏳ 플로우 복사 중... │ │ +│ │ ⬜ 화면 복사 대기 │ │ +│ │ ⬜ 메뉴 복사 대기 │ │ +│ │ ⬜ 코드 복사 대기 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소 불가] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 완료 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ✅ 복사 완료! │ │ +│ │ │ │ +│ │ • 메뉴: 5개 │ │ +│ │ • 화면: 12개 │ │ +│ │ • 플로우: 3개 │ │ +│ │ • 코드 카테고리: 8개 │ │ +│ │ • 코드: 45개 │ │ +│ │ │ │ +│ │ ⚠️ 주의사항: │ │ +│ │ - 실제 데이터는 복사되지 않음 │ │ +│ │ - 메뉴 권한 설정 필요 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [닫기] │ +└─────────────────────────────────────────┘ +``` + +### 3. 사용자 흐름 + +``` +1. 메뉴 관리 화면 접속 + ↓ +2. 복사할 메뉴 옆 [복사] 버튼 클릭 + ↓ +3. 복사 다이얼로그 열림 + ↓ +4. 대상 회사 선택 + ↓ +5. [복사 시작] 버튼 클릭 + ↓ +6. 진행 상황 표시 (30초 ~ 2분) + ↓ +7. 완료 메시지 확인 + ↓ +8. [닫기] 버튼으로 다이얼로그 닫기 +``` + +--- + +## 예외 처리 + +### 1. 권한 검증 +```typescript +if (req.user!.companyCode !== "*") { + throw new Error("메뉴 복사는 최고 관리자만 가능합니다"); +} +``` + +### 2. 메뉴 존재 여부 +```typescript +const menu = await getMenuByObjid(menuObjid, client); +if (!menu) { + throw new Error("메뉴를 찾을 수 없습니다"); +} +``` + +### 3. 대상 회사 존재 여부 +```typescript +const company = await getCompanyByCode(targetCompanyCode, client); +if (!company) { + throw new Error("대상 회사가 존재하지 않습니다"); +} +``` + +### 4. 중복 메뉴 체크 +```typescript +// 같은 이름의 메뉴가 이미 있는지 확인 +const existingMenu = await getMenuByNameAndCompany( + menu.menu_name_kor, + targetCompanyCode, + client +); + +if (existingMenu) { + // 경고만 표시하고 진행 (사용자가 이름 변경 가능) + warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`); +} +``` + +### 5. 트랜잭션 롤백 +```typescript +try { + await client.query('BEGIN'); + // ... 복사 작업 + await client.query('COMMIT'); +} catch (error) { + await client.query('ROLLBACK'); + logger.error("메뉴 복사 실패, 롤백됨:", error); + throw error; +} +``` + +### 6. 무한 루프 방지 +```typescript +// 화면 참조 추적 시 +const visited = new Set(); + +function collectScreensRecursive(screenId: number) { + if (visited.has(screenId)) return; // 이미 방문함 + visited.add(screenId); + // ... 참조 화면 수집 +} +``` + +### 7. JSONB 파싱 오류 +```typescript +try { + const properties = JSON.parse(layout.properties); + // ... properties 처리 +} catch (error) { + logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error); + // 원본 그대로 사용 +} +``` + +### 8. 부분 실패 처리 +```typescript +// 플로우 복사 실패 시 경고만 표시하고 계속 진행 +try { + await copyFlows(flowIds, targetCompanyCode, userId, client); +} catch (error) { + logger.error("플로우 복사 실패:", error); + warnings.push("일부 플로우가 복사되지 않았습니다"); + // 계속 진행 (메뉴와 화면은 복사) +} +``` + +--- + +## 테스트 계획 + +### 단위 테스트 (Unit Tests) + +#### 1. 수집 함수 테스트 +```typescript +describe("MenuCopyService - Collection", () => { + test("collectMenuTree: 하위 메뉴 재귀 수집", async () => { + const menus = await collectMenuTree(100); + expect(menus.length).toBeGreaterThan(1); + expect(menus[0].objid).toBe(100); + }); + + test("collectScreens: 중복 제거", async () => { + const screenIds = await collectScreens([100, 101]); + const uniqueIds = new Set(screenIds); + expect(screenIds.length).toBe(uniqueIds.size); + }); + + test("extractReferencedScreens: 모달 참조 추출", async () => { + const referenced = extractReferencedScreens(10); + expect(referenced).toContain(26); // 모달 화면 ID + }); +}); +``` + +#### 2. 복사 함수 테스트 +```typescript +describe("MenuCopyService - Copy", () => { + test("copyFlows: 플로우 + 스텝 + 연결 복사", async () => { + const flowIdMap = await copyFlows( + new Set([5]), + "TEST_COMPANY", + "test_user", + client + ); + + expect(flowIdMap.size).toBe(1); + const newFlowId = flowIdMap.get(5); + expect(newFlowId).toBeDefined(); + + const steps = await getFlowSteps(newFlowId!, client); + expect(steps.length).toBeGreaterThan(0); + }); + + test("copyScreens: properties 내부 참조 업데이트", async () => { + const screenIdMap = await copyScreens( + new Set([10]), + "TEST_COMPANY", + new Map(), // flowIdMap + "test_user", + client + ); + + const newScreenId = screenIdMap.get(10); + const layouts = await getScreenLayouts(newScreenId!, client); + + // targetScreenId가 재매핑되었는지 확인 + const modalLayout = layouts.find( + l => l.properties?.componentConfig?.action?.type === "modal" + ); + expect(modalLayout?.properties.componentConfig.action.targetScreenId).not.toBe(26); + }); +}); +``` + +#### 3. ID 재매핑 테스트 +```typescript +describe("MenuCopyService - Remapping", () => { + test("updateReferencesInProperties: 모달 참조 업데이트", () => { + const properties = { + componentConfig: { + action: { + type: "modal", + targetScreenId: 26 + } + } + }; + + const screenIdMap = new Map([[26, 50]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.componentConfig.action.targetScreenId).toBe(50); + }); + + test("updateReferencesInProperties: 조건부 컨테이너 참조 업데이트", () => { + const properties = { + sections: [ + { id: "1", condition: "A", screenId: 10 }, + { id: "2", condition: "B", screenId: 11 } + ] + }; + + const screenIdMap = new Map([[10, 30], [11, 31]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.sections[0].screenId).toBe(30); + expect(updated.sections[1].screenId).toBe(31); + }); +}); +``` + +### 통합 테스트 (Integration Tests) + +#### 1. 전체 복사 플로우 +```typescript +describe("Menu Copy - Full Flow", () => { + let testMenuObjid: number; + let targetCompanyCode: string; + + beforeAll(async () => { + // 테스트 데이터 준비 + testMenuObjid = await createTestMenu(); + targetCompanyCode = "TEST_COMPANY_" + Date.now(); + await createTestCompany(targetCompanyCode); + }); + + afterAll(async () => { + // 테스트 데이터 정리 + await deleteTestData(targetCompanyCode); + }); + + test("메뉴 복사: 성공", async () => { + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + testMenuObjid, + targetCompanyCode, + "test_user" + ); + + expect(result.success).toBe(true); + expect(result.copiedMenus).toBeGreaterThan(0); + expect(result.copiedScreens).toBeGreaterThan(0); + + // 복사된 메뉴 검증 + const copiedMenus = await getMenusByCompany(targetCompanyCode); + expect(copiedMenus.length).toBe(result.copiedMenus); + + // 복사된 화면 검증 + const copiedScreens = await getScreensByCompany(targetCompanyCode); + expect(copiedScreens.length).toBe(result.copiedScreens); + }); + + test("복사된 화면이 정상 작동", async () => { + // 복사된 화면에서 데이터 조회 가능한지 확인 + const screens = await getScreensByCompany(targetCompanyCode); + const firstScreen = screens[0]; + + const layouts = await getScreenLayouts(firstScreen.screen_id); + expect(layouts.length).toBeGreaterThan(0); + }); +}); +``` + +#### 2. 트랜잭션 롤백 테스트 +```typescript +describe("Menu Copy - Rollback", () => { + test("실패 시 롤백", async () => { + const invalidCompanyCode = "INVALID_COMPANY"; + + const menuCopyService = new MenuCopyService(); + + await expect( + menuCopyService.copyMenu(100, invalidCompanyCode, "test_user") + ).rejects.toThrow(); + + // 롤백 확인: 데이터가 생성되지 않았는지 + const menus = await getMenusByCompany(invalidCompanyCode); + expect(menus.length).toBe(0); + }); +}); +``` + +### E2E 테스트 (End-to-End Tests) + +#### 1. UI 테스트 +```typescript +describe("Menu Copy - E2E", () => { + test("메뉴 관리 화면에서 복사 버튼 클릭", async () => { + // 1. 로그인 + await page.goto("http://localhost:9771/login"); + await page.fill('input[name="userId"]', "wace"); + await page.fill('input[name="password"]', "qlalfqjsgh11"); + await page.click('button[type="submit"]'); + + // 2. 메뉴 관리 화면 이동 + await page.goto("http://localhost:9771/admin/menus"); + await page.waitForSelector(".menu-list"); + + // 3. 복사 버튼 클릭 + await page.click('button[aria-label="메뉴 복사"]'); + + // 4. 대상 회사 선택 + await page.selectOption('select[name="targetCompany"]', "COMPANY_B"); + + // 5. 복사 시작 + await page.click('button:has-text("복사 시작")'); + + // 6. 완료 메시지 확인 + await page.waitForSelector('text=복사 완료', { timeout: 120000 }); + + // 7. 복사된 메뉴 확인 + await page.selectOption('select[name="company"]', "COMPANY_B"); + await page.waitForSelector('.menu-list'); + const menuCount = await page.locator('.menu-item').count(); + expect(menuCount).toBeGreaterThan(0); + }); +}); +``` + +### 성능 테스트 + +#### 1. 대량 메뉴 복사 +```typescript +test("100개 메뉴 복사 성능", async () => { + const startTime = Date.now(); + + const result = await menuCopyService.copyMenu( + largeMenuObjid, // 하위 메뉴 100개 + "TEST_COMPANY", + "test_user" + ); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(duration).toBeLessThan(120000); // 2분 이내 + expect(result.copiedMenus).toBe(100); +}); +``` + +#### 2. 동시 복사 요청 +```typescript +test("동시 복사 요청 처리", async () => { + const promises = Array.from({ length: 5 }, (_, i) => + menuCopyService.copyMenu( + testMenuObjid, + `TEST_COMPANY_${i}`, + "test_user" + ) + ); + + const results = await Promise.all(promises); + + expect(results.every(r => r.success)).toBe(true); +}); +``` + +--- + +## 구현 체크리스트 + +### 백엔드 +- [ ] `menuCopyService.ts` 생성 + - [ ] `collectMenuTree()` + - [ ] `collectScreens()` + - [ ] `collectFlows()` + - [ ] `collectCodes()` + - [ ] `extractReferencedScreens()` + - [ ] `copyFlows()` + - [ ] `copyScreens()` + - [ ] `copyMenus()` + - [ ] `copyCodes()` + - [ ] `createScreenMenuAssignments()` + - [ ] `updateReferencesInProperties()` + - [ ] `topologicalSortMenus()` + - [ ] `generateUniqueScreenCode()` +- [ ] `menuController.ts` 업데이트 + - [ ] `copyMenu()` 컨트롤러 추가 +- [ ] `admin.ts` 라우터 업데이트 + - [ ] `/menus/:menuObjid/copy` 라우트 추가 +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 작성 + +### 프론트엔드 +- [ ] `menu.ts` API 클라이언트 업데이트 + - [ ] `copyMenu()` 함수 추가 + - [ ] `MenuCopyResult` 인터페이스 추가 +- [ ] `MenuCopyDialog.tsx` 생성 + - [ ] 회사 선택 드롭다운 + - [ ] 복사 진행 상태 표시 + - [ ] 복사 결과 표시 +- [ ] `MenuManagement.tsx` 업데이트 + - [ ] 복사 버튼 추가 + - [ ] 다이얼로그 통합 +- [ ] E2E 테스트 작성 + +### 문서 +- [ ] API 문서 업데이트 +- [ ] 사용자 매뉴얼 작성 +- [ ] 개발자 가이드 작성 + +--- + +## 예상 소요 시간 + +| 단계 | 작업 | 예상 시간 | +|------|------|-----------| +| 1 | 백엔드 서비스 구현 | 6시간 | +| 2 | 백엔드 컨트롤러/라우터 | 1시간 | +| 3 | 백엔드 테스트 | 3시간 | +| 4 | 프론트엔드 API 클라이언트 | 0.5시간 | +| 5 | 프론트엔드 UI 구현 | 3시간 | +| 6 | 프론트엔드 통합 | 1시간 | +| 7 | E2E 테스트 | 2시간 | +| 8 | 문서 작성 | 1.5시간 | +| 9 | 버그 수정 및 최적화 | 2시간 | +| **총계** | | **20시간** | + +--- + +## 참고 사항 + +### 멀티테넌시 주의사항 +- 모든 쿼리에 `company_code` 필터링 적용 +- 최고 관리자(company_code = "*")만 메뉴 복사 가능 +- 복사 시 `company_code`를 대상 회사 코드로 변경 + +### 데이터 무결성 +- 외래키 제약조건 순서 준수 +- 트랜잭션으로 원자성 보장 +- 중복 데이터 체크 및 병합 + +### 성능 최적화 +- 배치 삽입 사용 (bulk insert) +- 불필요한 쿼리 최소화 +- ID 매핑 테이블로 참조 업데이트 + +### 보안 +- 권한 검증 (최고 관리자만) +- SQL 인젝션 방지 +- 입력값 검증 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 2025-01-24 | 1.0 | AI | 초안 작성 | + + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx new file mode 100644 index 00000000..138e835b --- /dev/null +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { menuApi, MenuCopyResult } from "@/lib/api/menu"; +import { apiClient } from "@/lib/api/client"; + +interface MenuCopyDialogProps { + menuObjid: number | null; + menuName: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onCopyComplete?: () => void; +} + +interface Company { + company_code: string; + company_name: string; +} + +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, + onCopyComplete, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + const [loadingCompanies, setLoadingCompanies] = useState(false); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + // 다이얼로그가 열릴 때마다 초기화 + setTargetCompanyCode(""); + setResult(null); + } + }, [open]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const response = await apiClient.get("/admin/companies/db"); + if (response.data.success && response.data.data) { + // 최고 관리자(*) 회사 제외 + const filteredCompanies = response.data.data.filter( + (company: Company) => company.company_code !== "*" + ); + setCompanies(filteredCompanies); + } + } catch (error) { + console.error("회사 목록 조회 실패:", error); + toast.error("회사 목록을 불러올 수 없습니다"); + } finally { + setLoadingCompanies(false); + } + }; + + const handleCopy = async () => { + if (!menuObjid) { + toast.error("메뉴를 선택해주세요"); + return; + } + + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + try { + const response = await menuApi.copyMenu(menuObjid, targetCompanyCode); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + + // 경고 메시지 표시 + if (response.data.warnings && response.data.warnings.length > 0) { + response.data.warnings.forEach((warning) => { + toast.warning(warning); + }); + } + + // 복사 완료 콜백 + if (onCopyComplete) { + onCopyComplete(); + } + } else { + toast.error(response.message || "메뉴 복사 실패"); + } + } catch (error: any) { + console.error("메뉴 복사 오류:", error); + toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다"); + } finally { + setCopying(false); + } + }; + + const handleClose = () => { + if (!copying) { + onOpenChange(false); + } + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} + {!result && ( +
+ + +
+ )} + + {/* 복사 항목 안내 */} + {!result && ( +
+

복사되는 항목:

+
    +
  • 메뉴 구조 (하위 메뉴 포함)
  • +
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • +
  • 플로우 제어 (스텝, 연결)
  • +
  • 코드 카테고리 + 코드
  • +
+

+ ⚠️ 실제 데이터는 복사되지 않습니다. +

+
+ )} + + {/* 복사 결과 */} + {result && ( +
+

✅ 복사 완료!

+
+
+ 메뉴:{" "} + {result.copiedMenus}개 +
+
+ 화면:{" "} + {result.copiedScreens}개 +
+
+ 플로우:{" "} + {result.copiedFlows}개 +
+
+ 코드 카테고리:{" "} + {result.copiedCategories}개 +
+
+ 코드:{" "} + {result.copiedCodes}개 +
+
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} + diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx index 6671504e..67e8bab6 100644 --- a/frontend/components/admin/MenuManagement.tsx +++ b/frontend/components/admin/MenuManagement.tsx @@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu"; import type { MenuItem } from "@/lib/api/menu"; import { MenuTable } from "./MenuTable"; import { MenuFormModal } from "./MenuFormModal"; +import { MenuCopyDialog } from "./MenuCopyDialog"; import { Button } from "@/components/ui/button"; import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; @@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext"; import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; import { useMultiLang } from "@/hooks/useMultiLang"; import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; // useAuth 추가 type MenuType = "admin" | "user"; export const MenuManagement: React.FC = () => { const { adminMenus, userMenus, refreshMenus } = useMenu(); + const { user } = useAuth(); // 현재 사용자 정보 가져오기 const [selectedMenuType, setSelectedMenuType] = useState("admin"); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); const [formModalOpen, setFormModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [selectedMenuId, setSelectedMenuId] = useState(""); + const [selectedMenuName, setSelectedMenuName] = useState(""); const [selectedMenus, setSelectedMenus] = useState>(new Set()); // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) @@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => { // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 const { userLang } = useMultiLang({ companyCode: "*" }); + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = user?.userType === "SUPER_ADMIN"; + // 다국어 텍스트 상태 const [uiTexts, setUiTexts] = useState>({}); const [uiTextsLoading, setUiTextsLoading] = useState(false); @@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => { } }; + const handleCopyMenu = (menuId: string, menuName: string) => { + setSelectedMenuId(menuId); + setSelectedMenuName(menuName); + setCopyDialogOpen(true); + }; + + const handleCopyComplete = async () => { + // 복사 완료 후 메뉴 목록 새로고침 + await loadMenus(false); + toast.success("메뉴 복사가 완료되었습니다"); + }; + const handleToggleStatus = async (menuId: string) => { try { const response = await menuApi.toggleMenuStatus(menuId); @@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => { title="" onAddMenu={handleAddMenu} onEditMenu={handleEditMenu} + onCopyMenu={handleCopyMenu} onToggleStatus={handleToggleStatus} selectedMenus={selectedMenus} onMenuSelectionChange={handleMenuSelectionChange} @@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => { expandedMenus={expandedMenus} onToggleExpand={handleToggleExpand} uiTexts={uiTexts} + isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달 />
@@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => { + + ); }; diff --git a/frontend/components/admin/MenuTable.tsx b/frontend/components/admin/MenuTable.tsx index 9ca243bc..644c84f3 100644 --- a/frontend/components/admin/MenuTable.tsx +++ b/frontend/components/admin/MenuTable.tsx @@ -14,6 +14,7 @@ interface MenuTableProps { title: string; onAddMenu: (parentId: string, menuType: string, level: number) => void; onEditMenu: (menuId: string) => void; + onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가 onToggleStatus: (menuId: string) => void; selectedMenus: Set; onMenuSelectionChange: (menuId: string, checked: boolean) => void; @@ -22,6 +23,7 @@ interface MenuTableProps { onToggleExpand: (menuId: string) => void; // 다국어 텍스트 props 추가 uiTexts: Record; + isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가 } export const MenuTable: React.FC = ({ @@ -29,6 +31,7 @@ export const MenuTable: React.FC = ({ title, onAddMenu, onEditMenu, + onCopyMenu, onToggleStatus, selectedMenus, onMenuSelectionChange, @@ -36,6 +39,7 @@ export const MenuTable: React.FC = ({ expandedMenus, onToggleExpand, uiTexts, + isSuperAdmin = false, // 기본값 false }) => { // 다국어 텍스트 가져오기 함수 const getText = (key: string, fallback?: string): string => { @@ -281,14 +285,26 @@ export const MenuTable: React.FC = ({
{lev === 1 && ( - + <> + + {isSuperAdmin && ( + + )} + )} {lev === 2 && ( <> @@ -308,17 +324,39 @@ export const MenuTable: React.FC = ({ > {getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)} + {isSuperAdmin && ( + + )} )} {lev > 2 && ( - + <> + + {isSuperAdmin && ( + + )} + )}
diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index ad28996e..d8964257 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -162,4 +162,40 @@ export const menuApi = { throw error; } }, + + // 메뉴 복사 + copyMenu: async ( + menuObjid: number, + targetCompanyCode: string + ): Promise> => { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { targetCompanyCode } + ); + return response.data; + } catch (error: any) { + console.error("❌ 메뉴 복사 실패:", error); + return { + success: false, + message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다", + errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR", + }; + } + }, }; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings?: string[]; +} From 9b7416b6f8b028f6adc141162f7e89b46a342282 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 14:58:57 +0900 Subject: [PATCH 22/30] =?UTF-8?q?fix:=20source=5Fmenu=5Fobjid=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20-=20BigIn?= =?UTF-8?q?t=20=ED=83=80=EC=9E=85=20=EB=B9=84=EA=B5=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - PostgreSQL BIGINT 타입이 JavaScript에서 문자열로 반환됨 - menu.objid === rootMenuObjid 비교가 항상 false (타입 불일치) - 결과: source_menu_objid가 NULL로 저장되어 덮어쓰기 기능 작동 안 함 해결: - String() 변환 후 비교: String(menu.objid) === String(rootMenuObjid) - 타입에 관계없이 값 비교 가능 - source_menu_objid 정상 저장되어 덮어쓰기 기능 작동 검증: - 로그: '📌 source_menu_objid 저장: 1762407678882 (원본 최상위 메뉴)' - DB: menu_info.source_menu_objid = 1762407678882 ✅ 관련 파일: - backend-node/src/services/menuCopyService.ts --- backend-node/src/services/menuCopyService.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 4a9fc8bf..17ee7505 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -713,6 +713,7 @@ export class MenuCopyService { logger.info("\n📂 [4단계] 메뉴 복사"); const menuIdMap = await this.copyMenus( menus, + sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 targetCompanyCode, screenIdMap, userId, @@ -1138,6 +1139,7 @@ export class MenuCopyService { */ private async copyMenus( menus: Menu[], + rootMenuObjid: number, targetCompanyCode: string, screenIdMap: Map, userId: string, @@ -1170,15 +1172,14 @@ export class MenuCopyService { menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; } - // source_menu_objid 저장: 최상위 메뉴는 원본 ID, 하위 메뉴는 최상위의 원본 ID - const sourceMenuObjid = - !menu.parent_obj_id || menu.parent_obj_id === 0 - ? menu.objid // 최상위 메뉴: 자신의 ID가 원본 - : null; // 하위 메뉴: NULL (최상위만 추적) + // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) + // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + const sourceMenuObjid = isRootMenu ? menu.objid : null; if (sourceMenuObjid) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` ); } From 3be98234a80221a8ec66334a7554a89a9d3a3044 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 15:17:38 +0900 Subject: [PATCH 23/30] =?UTF-8?q?fix:=20conditional-container=20sections?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - extractReferencedScreens()에서 props.sections를 체크 - 실제 데이터 구조는 props.componentConfig.sections - 결과: conditional-container 안의 화면들이 수집되지 않음 - 예: 화면 205의 sections에 있는 202, 208 누락 해결: - props.sections → props.componentConfig.sections - conditional-container 안의 모든 화면 정상 수집 - 재귀 복사 로직은 이미 완벽하게 작동 중 참고: - 모달 안의 모달(재귀 참조)은 이미 정상 작동 - 예: 157 → 253 → 254 (3단계 재귀) ✅ 관련 파일: - backend-node/src/services/menuCopyService.ts --- backend-node/src/services/menuCopyService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 17ee7505..90f49770 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -232,8 +232,11 @@ export class MenuCopyService { } // 2) 조건부 컨테이너 (숫자 또는 문자열) - if (props?.sections && Array.isArray(props.sections)) { - for (const section of props.sections) { + if ( + props?.componentConfig?.sections && + Array.isArray(props.componentConfig.sections) + ) { + for (const section of props.componentConfig.sections) { if (section.screenId) { const screenId = section.screenId; const numId = From 14802f507fd7041a3070f382de8ac9ca53b03231 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 15:27:54 +0900 Subject: [PATCH 24/30] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=B1=84=EB=B2=88?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EB=B3=B5=EC=82=AC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로운 기능: 1. 카테고리 컬럼 매핑(category_column_mapping) 복사 2. 테이블 컬럼 카테고리 값(table_column_category_values) 복사 3. 채번 규칙(numbering_rules) 복사 4. 채번 규칙 파트(numbering_rule_parts) 복사 중복 처리: - 모든 항목: 스킵(Skip) 정책 적용 - 이미 존재하는 데이터는 덮어쓰지 않고 건너뜀 - 카테고리 값: 부모-자식 관계 유지를 위해 기존 ID 매핑 저장 채번 규칙 특징: - 구조(파트)는 그대로 복사 - 순번(current_sequence)은 1부터 초기화 - rule_id는 타임스탬프 기반으로 새로 생성 (항상 고유) 복사 프로세스: - [7단계] 카테고리 설정 복사 - [8단계] 채번 규칙 복사 결과 로그: - 컬럼 매핑, 카테고리 값, 규칙, 파트 개수 표시 - 스킵된 항목 개수도 함께 표시 이제 메뉴 복사 시 카테고리와 채번 규칙도 함께 복사되어 복사한 회사에서 바로 업무를 시작할 수 있습니다. 관련 파일: - backend-node/src/services/menuCopyService.ts --- backend-node/src/services/menuCopyService.ts | 330 +++++++++++++++++++ 1 file changed, 330 insertions(+) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 90f49770..5551fa32 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -12,6 +12,8 @@ export interface MenuCopyResult { copiedFlows: number; copiedCategories: number; copiedCodes: number; + copiedCategorySettings: number; + copiedNumberingRules: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -392,6 +394,88 @@ export class MenuCopyService { return { categories, codes }; } + /** + * 카테고리 설정 수집 + */ + private async collectCategorySettings( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ + columnMappings: any[]; + categoryValues: any[]; + }> { + logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`); + + const columnMappings: any[] = []; + const categoryValues: any[] = []; + + for (const menuObjid of menuObjids) { + // 카테고리 컬럼 매핑 + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + columnMappings.push(...mappingsResult.rows); + + // 테이블 컬럼 카테고리 값 + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + categoryValues.push(...valuesResult.rows); + } + + logger.info( + `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개, 카테고리 값 ${categoryValues.length}개` + ); + return { columnMappings, categoryValues }; + } + + /** + * 채번 규칙 수집 + */ + private async collectNumberingRules( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ + rules: any[]; + parts: any[]; + }> { + logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`); + + const rules: any[] = []; + const parts: any[] = []; + + for (const menuObjid of menuObjids) { + // 채번 규칙 + const rulesResult = await client.query( + `SELECT * FROM numbering_rules + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + rules.push(...rulesResult.rows); + + // 각 규칙의 파트 + for (const rule of rulesResult.rows) { + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2`, + [rule.rule_id, sourceCompanyCode] + ); + parts.push(...partsResult.rows); + } + } + + logger.info( + `✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개` + ); + return { rules, parts }; + } + /** * 다음 메뉴 objid 생성 */ @@ -684,6 +768,18 @@ export class MenuCopyService { client ); + const categorySettings = await this.collectCategorySettings( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const numberingRules = await this.collectNumberingRules( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + logger.info(` 📊 수집 완료: - 메뉴: ${menus.length}개 @@ -691,6 +787,8 @@ export class MenuCopyService { - 플로우: ${flowIds.size}개 - 코드 카테고리: ${codes.categories.length}개 - 코드: ${codes.codes.length}개 + - 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개 + - 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개 `); // === 2단계: 플로우 복사 === @@ -737,6 +835,26 @@ export class MenuCopyService { logger.info("\n📋 [6단계] 코드 복사"); await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); + // === 7단계: 카테고리 설정 복사 === + logger.info("\n📂 [7단계] 카테고리 설정 복사"); + await this.copyCategorySettings( + categorySettings, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // === 8단계: 채번 규칙 복사 === + logger.info("\n📋 [8단계] 채번 규칙 복사"); + await this.copyNumberingRules( + numberingRules, + menuIdMap, + targetCompanyCode, + userId, + client + ); + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -748,6 +866,11 @@ export class MenuCopyService { copiedFlows: flowIdMap.size, copiedCategories: codes.categories.length, copiedCodes: codes.codes.length, + copiedCategorySettings: + categorySettings.columnMappings.length + + categorySettings.categoryValues.length, + copiedNumberingRules: + numberingRules.rules.length + numberingRules.parts.length, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -762,6 +885,8 @@ export class MenuCopyService { - 플로우: ${result.copiedFlows}개 - 코드 카테고리: ${result.copiedCategories}개 - 코드: ${result.copiedCodes}개 + - 카테고리 설정: ${result.copiedCategorySettings}개 + - 채번 규칙: ${result.copiedNumberingRules}개 ============================================ `); @@ -1440,4 +1565,209 @@ export class MenuCopyService { `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` ); } + + /** + * 카테고리 설정 복사 + */ + private async copyCategorySettings( + settings: { columnMappings: any[]; categoryValues: any[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📂 카테고리 설정 복사 중...`); + + const valueIdMap = new Map(); // 원본 value_id → 새 value_id + let mappingCount = 0; + let valueCount = 0; + + // 1) 카테고리 컬럼 매핑 복사 + for (const mapping of settings.columnMappings) { + const newMenuObjid = menuIdMap.get(mapping.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const existsResult = await client.query( + `SELECT mapping_id FROM category_column_mapping + WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, + [mapping.table_name, mapping.physical_column_name, targetCompanyCode] + ); + + if (existsResult.rows.length > 0) { + logger.debug( + ` ⏭️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.physical_column_name}` + ); + continue; + } + + await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + mapping.table_name, + mapping.logical_column_name, + mapping.physical_column_name, + newMenuObjid, + targetCompanyCode, + mapping.description, + userId, + ] + ); + + mappingCount++; + } + + // 2) 테이블 컬럼 카테고리 값 복사 (부모-자식 관계 유지) + const sortedValues = settings.categoryValues.sort( + (a, b) => a.depth - b.depth + ); + let skippedValues = 0; + + for (const value of sortedValues) { + const newMenuObjid = menuIdMap.get(value.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const existsResult = await client.query( + `SELECT value_id FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, + [value.table_name, value.column_name, value.value_code, targetCompanyCode] + ); + + if (existsResult.rows.length > 0) { + skippedValues++; + logger.debug( + ` ⏭️ 카테고리 값 이미 존재: ${value.table_name}.${value.column_name}.${value.value_code}` + ); + // 기존 값의 ID를 매핑에 저장 (자식 항목의 parent_id 재매핑용) + valueIdMap.set(value.value_id, existsResult.rows[0].value_id); + continue; + } + + // 부모 ID 재매핑 + let newParentValueId = null; + if (value.parent_value_id) { + newParentValueId = valueIdMap.get(value.parent_value_id) || null; + } + + const result = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, + value_order, parent_value_id, depth, description, + color, icon, is_active, is_default, + company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING value_id`, + [ + value.table_name, + value.column_name, + value.value_code, + value.value_label, + value.value_order, + newParentValueId, + value.depth, + value.description, + value.color, + value.icon, + value.is_active, + value.is_default, + targetCompanyCode, + newMenuObjid, + userId, + ] + ); + + // ID 매핑 저장 + const newValueId = result.rows[0].value_id; + valueIdMap.set(value.value_id, newValueId); + + valueCount++; + } + + logger.info( + `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (${skippedValues}개 스킵)` + ); + } + + /** + * 채번 규칙 복사 + */ + private async copyNumberingRules( + rules: { rules: any[]; parts: any[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 채번 규칙 복사 중...`); + + const ruleIdMap = new Map(); // 원본 rule_id → 새 rule_id + let ruleCount = 0; + let partCount = 0; + + // 1) 채번 규칙 복사 + for (const rule of rules.rules) { + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (!newMenuObjid) continue; + + // 새 rule_id 생성 (타임스탬프 기반) + const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + ruleIdMap.set(rule.rule_id, newRuleId); + + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, + reset_period, current_sequence, table_name, column_name, + company_code, menu_objid, created_by, scope_type + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 1, // 시퀀스 초기화 + rule.table_name, + rule.column_name, + targetCompanyCode, + newMenuObjid, + userId, + rule.scope_type, + ] + ); + + ruleCount++; + } + + // 2) 채번 규칙 파트 복사 + for (const part of rules.parts) { + const newRuleId = ruleIdMap.get(part.rule_id); + if (!newRuleId) continue; + + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config, + part.manual_config, + targetCompanyCode, + ] + ); + + partCount++; + } + + logger.info( + `✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개` + ); + } } From 8b3593c8fbeb422c5452b01328ffbf244c81ab1b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 15:38:59 +0900 Subject: [PATCH 25/30] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=8B=9C=20=ED=99=94=EB=A9=B4=EB=AA=85=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EB=B3=80=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로운 기능: - 화면명에서 특정 텍스트 제거 (예: '탑씰' 제거) - 화면명에 접두사 추가 (예: '한신' 추가) - 변환 로직: 제거 → 접두사 추가 순서로 적용 백엔드: - menuCopyService.copyMenu()에 screenNameConfig 파라미터 추가 - copyScreens()에서 화면명 변환 로직 적용 - 정규식으로 전역 치환 (new RegExp(text, 'g')) 프론트엔드: - MenuCopyDialog에 화면명 일괄 변경 UI 추가 - Checkbox로 기능 활성화/비활성화 - 2개 Input: removeText, addPrefix - API 호출 시 screenNameConfig 전달 사용 예시: 1. '탑씰 회사정보' → '회사정보' (제거만) 2. '회사정보' → '한신 회사정보' (접두사만) 3. '탑씰 회사정보' → '한신 회사정보' (제거 + 접두사) 관련 파일: - backend-node/src/services/menuCopyService.ts - backend-node/src/controllers/adminController.ts - frontend/lib/api/menu.ts - frontend/components/admin/MenuCopyDialog.tsx --- .../src/controllers/adminController.ts | 11 ++- backend-node/src/services/menuCopyService.ts | 43 ++++++++-- frontend/components/admin/MenuCopyDialog.tsx | 84 ++++++++++++++++++- frontend/lib/api/menu.ts | 11 ++- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bfc1f3b1..746bf931 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3308,12 +3308,21 @@ export async function copyMenu( return; } + // 화면명 변환 설정 (선택사항) + const screenNameConfig = req.body.screenNameConfig + ? { + removeText: req.body.screenNameConfig.removeText, + addPrefix: req.body.screenNameConfig.addPrefix, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, - userId + userId, + screenNameConfig ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 5551fa32..7187dd2e 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -726,7 +726,11 @@ export class MenuCopyService { async copyMenu( sourceMenuObjid: number, targetCompanyCode: string, - userId: string + userId: string, + screenNameConfig?: { + removeText?: string; + addPrefix?: string; + } ): Promise { logger.info(` 🚀 ============================================ @@ -807,7 +811,8 @@ export class MenuCopyService { targetCompanyCode, flowIdMap, userId, - client + client, + screenNameConfig ); // === 4단계: 메뉴 복사 === @@ -1048,7 +1053,11 @@ export class MenuCopyService { targetCompanyCode: string, flowIdMap: Map, userId: string, - client: PoolClient + client: PoolClient, + screenNameConfig?: { + removeText?: string; + addPrefix?: string; + } ): Promise> { const screenIdMap = new Map(); @@ -1087,6 +1096,25 @@ export class MenuCopyService { client ); + // 2-1) 화면명 변환 적용 + let transformedScreenName = screenDef.screen_name; + if (screenNameConfig) { + // 1. 제거할 텍스트 제거 + if (screenNameConfig.removeText?.trim()) { + transformedScreenName = transformedScreenName.replace( + new RegExp(screenNameConfig.removeText.trim(), "g"), + "" + ); + transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + } + + // 2. 접두사 추가 + if (screenNameConfig.addPrefix?.trim()) { + transformedScreenName = + screenNameConfig.addPrefix.trim() + " " + transformedScreenName; + } + } + // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) const newScreenResult = await client.query<{ screen_id: number }>( `INSERT INTO screen_definitions ( @@ -1097,7 +1125,7 @@ export class MenuCopyService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING screen_id`, [ - screenDef.screen_name, + transformedScreenName, // 변환된 화면명 newScreenCode, // 새 화면 코드 screenDef.table_name, targetCompanyCode, // 새 회사 코드 @@ -1634,7 +1662,12 @@ export class MenuCopyService { const existsResult = await client.query( `SELECT value_id FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, - [value.table_name, value.column_name, value.value_code, targetCompanyCode] + [ + value.table_name, + value.column_name, + value.value_code, + targetCompanyCode, + ] ); if (existsResult.rows.length > 0) { diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index 138e835b..46de8f4b 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -13,6 +13,8 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -49,6 +51,11 @@ export function MenuCopyDialog({ const [result, setResult] = useState(null); const [loadingCompanies, setLoadingCompanies] = useState(false); + // 화면명 일괄 변경 설정 + const [useBulkRename, setUseBulkRename] = useState(false); + const [removeText, setRemoveText] = useState(""); + const [addPrefix, setAddPrefix] = useState(""); + // 회사 목록 로드 useEffect(() => { if (open) { @@ -56,6 +63,9 @@ export function MenuCopyDialog({ // 다이얼로그가 열릴 때마다 초기화 setTargetCompanyCode(""); setResult(null); + setUseBulkRename(false); + setRemoveText(""); + setAddPrefix(""); } }, [open]); @@ -93,7 +103,20 @@ export function MenuCopyDialog({ setResult(null); try { - const response = await menuApi.copyMenu(menuObjid, targetCompanyCode); + // 화면명 변환 설정 (사용 중일 때만 전달) + const screenNameConfig = + useBulkRename && (removeText.trim() || addPrefix.trim()) + ? { + removeText: removeText.trim() || undefined, + addPrefix: addPrefix.trim() || undefined, + } + : undefined; + + const response = await menuApi.copyMenu( + menuObjid, + targetCompanyCode, + screenNameConfig + ); if (response.success && response.data) { setResult(response.data); @@ -183,6 +206,64 @@ export function MenuCopyDialog({
)} + {/* 화면명 일괄 변경 설정 */} + {!result && ( +
+
+ setUseBulkRename(checked as boolean)} + disabled={copying} + /> + +
+ + {useBulkRename && ( +
+
+ + setRemoveText(e.target.value)} + placeholder="예: 탑씰" + disabled={copying} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 화면명에서 이 텍스트를 제거합니다 (예: "탑씰 회사정보" → "회사정보") +

+
+ +
+ + setAddPrefix(e.target.value)} + placeholder="예: 한신" + disabled={copying} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 화면명 앞에 이 텍스트를 추가합니다 (예: "회사정보" → "한신 회사정보") +

+
+
+ )} +
+ )} + {/* 복사 항목 안내 */} {!result && (
@@ -192,6 +273,7 @@ export function MenuCopyDialog({
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • 플로우 제어 (스텝, 연결)
  • 코드 카테고리 + 코드
  • +
  • 카테고리 설정 + 채번 규칙
  • ⚠️ 실제 데이터는 복사되지 않습니다. diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index d8964257..a39fc7c6 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -166,12 +166,19 @@ export const menuApi = { // 메뉴 복사 copyMenu: async ( menuObjid: number, - targetCompanyCode: string + targetCompanyCode: string, + screenNameConfig?: { + removeText?: string; + addPrefix?: string; + } ): Promise> => { try { const response = await apiClient.post( `/admin/menus/${menuObjid}/copy`, - { targetCompanyCode } + { + targetCompanyCode, + screenNameConfig + } ); return response.data; } catch (error: any) { From 10526da1ac9369ce2e0339bfa902b40f102a40ea Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 15:58:00 +0900 Subject: [PATCH 26/30] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=A0=95=20=EB=8D=AE=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A8=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 동작: - 카테고리 컬럼 매핑: 중복 시 스킵 - 카테고리 값: 중복 시 스킵 - 결과: 일부 값만 복사되어 불완전 새로운 동작 (덮어쓰기): - 카테고리 컬럼 매핑: 기존 것 삭제 후 재생성 - 카테고리 값: 테이블+컬럼 단위로 기존 것 전체 삭제 후 재생성 - 부모-자식 관계는 유지 (depth 순으로 정렬 후 복사) 장점: 1. 메뉴 재복사 시 항상 최신 카테고리 설정으로 덮어씀 2. 누락된 값 없이 완전한 복사 보장 3. 테스트 시 기존 데이터 정리 불필요 주의사항: - 기존 카테고리 값이 다른 데이터에서 참조되는 경우 외래키 제약조건 위반 가능 - 실무에서는 사용자 선택 옵션(덮어쓰기/병합)을 추가하는 것이 안전 관련 파일: - backend-node/src/services/menuCopyService.ts 테스트: - COMPANY_11로 재복사 시 모든 카테고리 값 정상 복사됨 --- backend-node/src/services/menuCopyService.ts | 62 ++++++++------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 7187dd2e..3471cdad 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1610,25 +1610,19 @@ export class MenuCopyService { let mappingCount = 0; let valueCount = 0; - // 1) 카테고리 컬럼 매핑 복사 + // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) for (const mapping of settings.columnMappings) { const newMenuObjid = menuIdMap.get(mapping.menu_objid); if (!newMenuObjid) continue; - // 중복 체크 - const existsResult = await client.query( - `SELECT mapping_id FROM category_column_mapping + // 기존 매핑 삭제 (덮어쓰기) + await client.query( + `DELETE FROM category_column_mapping WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, [mapping.table_name, mapping.physical_column_name, targetCompanyCode] ); - if (existsResult.rows.length > 0) { - logger.debug( - ` ⏭️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.physical_column_name}` - ); - continue; - } - + // 새 매핑 추가 await client.query( `INSERT INTO category_column_mapping ( table_name, logical_column_name, physical_column_name, @@ -1648,38 +1642,34 @@ export class MenuCopyService { mappingCount++; } - // 2) 테이블 컬럼 카테고리 값 복사 (부모-자식 관계 유지) + // 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지) const sortedValues = settings.categoryValues.sort( (a, b) => a.depth - b.depth ); - let skippedValues = 0; + // 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위) + const uniqueTableColumns = new Set(); + for (const value of sortedValues) { + uniqueTableColumns.add(`${value.table_name}:${value.column_name}`); + } + + for (const tableColumn of uniqueTableColumns) { + const [tableName, columnName] = tableColumn.split(":"); + await client.query( + `DELETE FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, + [tableName, columnName, targetCompanyCode] + ); + logger.debug( + ` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}` + ); + } + + // 새 값 추가 for (const value of sortedValues) { const newMenuObjid = menuIdMap.get(value.menu_objid); if (!newMenuObjid) continue; - // 중복 체크 - const existsResult = await client.query( - `SELECT value_id FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, - [ - value.table_name, - value.column_name, - value.value_code, - targetCompanyCode, - ] - ); - - if (existsResult.rows.length > 0) { - skippedValues++; - logger.debug( - ` ⏭️ 카테고리 값 이미 존재: ${value.table_name}.${value.column_name}.${value.value_code}` - ); - // 기존 값의 ID를 매핑에 저장 (자식 항목의 parent_id 재매핑용) - valueIdMap.set(value.value_id, existsResult.rows[0].value_id); - continue; - } - // 부모 ID 재매핑 let newParentValueId = null; if (value.parent_value_id) { @@ -1721,7 +1711,7 @@ export class MenuCopyService { } logger.info( - `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (${skippedValues}개 스킵)` + `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)` ); } From 42435193cfa8eb73293660710f96224ce1a56765 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 15:59:55 +0900 Subject: [PATCH 27/30] =?UTF-8?q?fix:=20=EB=8D=AE=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=20=EC=99=B8=EB=9E=98=ED=82=A4=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=9C=84=EB=B0=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 기존 메뉴 삭제 시 numbering_rules.fk_numbering_rules_menu 외래키 제약조건 위반 - category_column_mapping.fk_mapping_menu 외래키 제약조건도 위반 가능 원인: - 채번 규칙과 카테고리 설정이 menu_objid를 참조하는데, 메뉴를 먼저 삭제하려고 함 해결: deleteExistingCopy 함수의 삭제 순서 변경: 1. 화면 레이아웃 2. 화면-메뉴 할당 3. 화면 정의 4. 메뉴 권한 5. 채번 규칙 파트 (추가) 6. 채번 규칙 (추가) 7. 테이블 컬럼 카테고리 값 (추가) 8. 카테고리 컬럼 매핑 (추가) 9. 메뉴 (역순) 관련 파일: - backend-node/src/services/menuCopyService.ts 테스트: - 메뉴 덮어쓰기 재복사 시 외래키 제약조건 위반 없이 정상 동작 --- backend-node/src/services/menuCopyService.ts | 41 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 3471cdad..19b2d96f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -709,7 +709,42 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-5. 채번 규칙 파트 삭제 + await client.query( + `DELETE FROM numbering_rule_parts + WHERE rule_id IN ( + SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 + )`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 채번 규칙 파트 삭제 완료`); + + // 5-6. 채번 규칙 삭제 + await client.query( + `DELETE FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 채번 규칙 삭제 완료`); + + // 5-7. 테이블 컬럼 카테고리 값 삭제 + await client.query( + `DELETE FROM table_column_category_values + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 카테고리 값 삭제 완료`); + + // 5-8. 카테고리 컬럼 매핑 삭제 + await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 카테고리 매핑 삭제 완료`); + + // 5-9. 메뉴 삭제 (역순: 하위 메뉴부터) for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ existingMenus[i].objid, @@ -1660,9 +1695,7 @@ export class MenuCopyService { WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, [tableName, columnName, targetCompanyCode] ); - logger.debug( - ` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}` - ); + logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`); } // 새 값 추가 From be48d30d8f4f3886dadd9ecc5cf41cd5a2b06647 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 16:04:04 +0900 Subject: [PATCH 28/30] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=A4=EC=A0=95(menu=5Fobjid?= =?UTF-8?q?=3D0)=20=EB=B3=B5=EC=82=AC=20=EB=88=84=EB=9D=BD=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - menu_objid = 0인 공통 카테고리 값들이 복사되지 않음 - 원본 34개 중 15개만 복사됨 (19개 누락) - customer_mng, item_info 등의 공통 카테고리 값들이 프론트엔드에서 안 보임 원인: - collectCategorySettings: menu_objid로만 WHERE 필터링 - copyCategorySettings: menuIdMap.get()이 0을 찾지 못함 해결: 1. collectCategorySettings 함수: - WHERE menu_objid = ANY($1) OR menu_objid = 0 - 공통 카테고리 설정도 함께 수집 2. copyCategorySettings 함수: - menu_objid = 0일 경우 그대로 0으로 유지 - if (newMenuObjid === undefined) 체크로 안전성 강화 영향: - 공통 카테고리 값(division, status, currency_code 등) 정상 복사 - 모든 화면에서 카테고리 값 정상 표시 테스트: - 원본 34개 → 복사본 34개 (100% 복사) - customer_mng.division, item_info.division 등 정상 동작 --- backend-node/src/services/menuCopyService.ts | 56 ++++++++++++-------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 19b2d96f..37ef1a64 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -410,26 +410,26 @@ export class MenuCopyService { const columnMappings: any[] = []; const categoryValues: any[] = []; - for (const menuObjid of menuObjids) { - // 카테고리 컬럼 매핑 - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - columnMappings.push(...mappingsResult.rows); + // 카테고리 컬럼 매핑 (메뉴별 + 공통) + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping + WHERE (menu_objid = ANY($1) OR menu_objid = 0) + AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); + columnMappings.push(...mappingsResult.rows); - // 테이블 컬럼 카테고리 값 - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - categoryValues.push(...valuesResult.rows); - } + // 테이블 컬럼 카테고리 값 (메뉴별 + 공통) + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE (menu_objid = ANY($1) OR menu_objid = 0) + AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); + categoryValues.push(...valuesResult.rows); logger.info( - `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개, 카테고리 값 ${categoryValues.length}개` + `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)` ); return { columnMappings, categoryValues }; } @@ -1647,8 +1647,15 @@ export class MenuCopyService { // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) for (const mapping of settings.columnMappings) { - const newMenuObjid = menuIdMap.get(mapping.menu_objid); - if (!newMenuObjid) continue; + // menu_objid = 0인 공통 설정은 그대로 0으로 유지 + const newMenuObjid = mapping.menu_objid === 0 + ? 0 + : menuIdMap.get(mapping.menu_objid); + + if (newMenuObjid === undefined) { + logger.debug(` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`); + continue; + } // 기존 매핑 삭제 (덮어쓰기) await client.query( @@ -1700,8 +1707,15 @@ export class MenuCopyService { // 새 값 추가 for (const value of sortedValues) { - const newMenuObjid = menuIdMap.get(value.menu_objid); - if (!newMenuObjid) continue; + // menu_objid = 0인 공통 설정은 그대로 0으로 유지 + const newMenuObjid = value.menu_objid === 0 + ? 0 + : menuIdMap.get(value.menu_objid); + + if (newMenuObjid === undefined) { + logger.debug(` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`); + continue; + } // 부모 ID 재매핑 let newParentValueId = null; From 3355ff4563afee14ccdf974d05fd43c13a1a227c Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 16:10:55 +0900 Subject: [PATCH 29/30] =?UTF-8?q?fix:=20menu=5Fobjid=3D0=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B0=92=20?= =?UTF-8?q?=EC=8A=A4=ED=82=B5=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - menu_objid = 0인 공통 카테고리 값들이 19개 스킵됨 - '⏭️ 매핑할 메뉴가 없음: menu_objid=0' 로그 반복 원인: - 삼항 연산자로 0을 할당했으나, 이후 if (newMenuObjid === undefined) 체크에서 - 0이 falsy 값이 아닌데도 undefined와 비교하여 통과하지 못함 - 실제로는 newMenuObjid가 0일 때도 continue되어 스킵됨 해결: - menu_objid = 0일 경우를 명시적으로 처리 - 0인 경우 바로 0을 할당하고 continue 없이 진행 - 0이 아닌 경우만 menuIdMap에서 찾고, undefined 체크 변경 전: const newMenuObjid = value.menu_objid === 0 ? 0 : menuIdMap.get(value.menu_objid); if (newMenuObjid === undefined) continue; // 0도 여기서 걸림! 변경 후: if (value.menu_objid === 0) { newMenuObjid = 0; // 공통 설정은 그대로 0 } else { newMenuObjid = menuIdMap.get(value.menu_objid); if (newMenuObjid === undefined) continue; // 진짜 undefined만 스킵 } 영향: - 공통 카테고리 값 19개 정상 복사 - customer_mng, item_info의 division, status, currency_code 등 정상 동작 --- backend-node/src/services/menuCopyService.ts | 34 +++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 37ef1a64..241cda9a 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1648,13 +1648,18 @@ export class MenuCopyService { // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) for (const mapping of settings.columnMappings) { // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - const newMenuObjid = mapping.menu_objid === 0 - ? 0 - : menuIdMap.get(mapping.menu_objid); + let newMenuObjid: number | undefined; - if (newMenuObjid === undefined) { - logger.debug(` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`); - continue; + if (mapping.menu_objid === 0) { + newMenuObjid = 0; // 공통 설정 + } else { + newMenuObjid = menuIdMap.get(mapping.menu_objid); + if (newMenuObjid === undefined) { + logger.debug( + ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}` + ); + continue; + } } // 기존 매핑 삭제 (덮어쓰기) @@ -1708,13 +1713,18 @@ export class MenuCopyService { // 새 값 추가 for (const value of sortedValues) { // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - const newMenuObjid = value.menu_objid === 0 - ? 0 - : menuIdMap.get(value.menu_objid); + let newMenuObjid: number | undefined; - if (newMenuObjid === undefined) { - logger.debug(` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`); - continue; + if (value.menu_objid === 0) { + newMenuObjid = 0; // 공통 설정 + } else { + newMenuObjid = menuIdMap.get(value.menu_objid); + if (newMenuObjid === undefined) { + logger.debug( + ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}` + ); + continue; + } } // 부모 ID 재매핑 From c1e5a2a5f142e04a5447fdd63f7004b55289809f Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 21 Nov 2025 16:23:37 +0900 Subject: [PATCH 30/30] =?UTF-8?q?fix:=20Select=20Basic=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=EC=84=A0=ED=83=9D=20=EB=86=92=EC=9D=B4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20menu=5Fobjid=3D0=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Select Basic 다중선택 컴포넌트 높이 문제 해결 - 외부 wrapper에 height: 100% 추가 - 내부 div에 인라인 스타일로 height: 100% 명시 - items-center 추가하여 태그 세로 가운데 정렬 - Tailwind h-full 클래스 제거로 스타일 충돌 방지 2. 메뉴 복사 시 menu_objid=0 공통 카테고리 타입 처리 - menu_objid가 숫자 0, 문자열 '0' 모두 처리 - == 0 타입 강제 변환으로 모든 경우 감지 - 카테고리 컬럼 매핑, 카테고리 값 모두 적용 - 공통 카테고리 19개 정상 복사 가능 --- backend-node/src/services/menuCopyService.ts | 16 ++++++++++++---- .../select-basic/SelectBasicComponent.tsx | 9 ++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 241cda9a..7d969b06 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1649,8 +1649,12 @@ export class MenuCopyService { for (const mapping of settings.columnMappings) { // menu_objid = 0인 공통 설정은 그대로 0으로 유지 let newMenuObjid: number | undefined; - - if (mapping.menu_objid === 0) { + + if ( + mapping.menu_objid === 0 || + mapping.menu_objid === "0" || + mapping.menu_objid == 0 + ) { newMenuObjid = 0; // 공통 설정 } else { newMenuObjid = menuIdMap.get(mapping.menu_objid); @@ -1714,8 +1718,12 @@ export class MenuCopyService { for (const value of sortedValues) { // menu_objid = 0인 공통 설정은 그대로 0으로 유지 let newMenuObjid: number | undefined; - - if (value.menu_objid === 0) { + + if ( + value.menu_objid === 0 || + value.menu_objid === "0" || + value.menu_objid == 0 + ) { newMenuObjid = 0; // 공통 설정 } else { newMenuObjid = menuIdMap.get(value.menu_objid); diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 7e12dda9..0e618b6e 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -640,15 +640,18 @@ const SelectBasicComponent: React.FC = ({ // 다중선택 모드인 경우 if (isMultiple) { return ( -

    +
    !isDesignMode && setIsOpen(true)} - style={{ pointerEvents: isDesignMode ? "none" : "auto" }} + style={{ + pointerEvents: isDesignMode ? "none" : "auto", + height: "100%" + }} > {selectedValues.map((val, idx) => { const opt = allOptions.find((o) => o.value === val);