"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { ChevronRight, ChevronDown, Search, Check, X, Languages, Table2, LayoutPanelLeft, Type, MousePointer, Filter, FormInput, ChevronsUpDown, Loader2, } from "lucide-react"; import { ComponentData } from "@/types/screen"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; // 다국어 키 타입 interface LangKey { keyId: number; langKey: string; description?: string; categoryName?: string; } // 입력 가능한 폼 컴포넌트 타입 목록 const INPUT_COMPONENT_TYPES = new Set([ "text-input", "number-input", "date-input", "datetime-input", "select-input", "textarea-input", "checkbox-input", "radio-input", "file-input", "email-input", "phone-input", "entity-input", "code-input", // 위젯 타입도 포함 "widget", "input", "field", ]); // 입력 가능한 웹타입 목록 const INPUT_WEB_TYPES = new Set([ "text", "number", "decimal", "date", "datetime", "select", "dropdown", "textarea", "boolean", "checkbox", "radio", "code", "entity", "file", "email", "tel", ]); // 추출된 라벨 항목 타입 interface ExtractedLabel { id: string; componentId: string; label: string; type: "label" | "title" | "button" | "placeholder" | "column" | "filter" | "field" | "tab" | "action"; parentType?: string; parentLabel?: string; langKeyId?: number; langKey?: string; } // 그룹화된 라벨 타입 interface LabelGroup { id: string; label: string; type: string; icon: React.ReactNode; items: ExtractedLabel[]; isExpanded: boolean; } interface MultilangSettingsModalProps { isOpen: boolean; onClose: () => void; components: ComponentData[]; onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void; } // 타입별 아이콘 매핑 const getTypeIcon = (type: string) => { switch (type) { case "button": return ; case "table-list": return ; case "split-panel-layout": return ; case "filter": return ; case "field": case "input": return ; default: return ; } }; // 타입별 한글 라벨 const getTypeLabel = (type: string) => { switch (type) { case "button": return "버튼"; case "label": return "라벨"; case "title": return "제목"; case "column": return "컬럼"; case "filter": return "필터"; case "field": return "필드"; case "placeholder": return "플레이스홀더"; case "tab": return "탭"; case "action": return "액션"; default: return type; } }; // 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등) const NON_INPUT_COMPONENT_TYPES = new Set([ "table-list", "split-panel-layout", "tab-panel", "container", "group", "layout", "panel", "card", "accordion", "modal", "drawer", "form-layout", ]); // 컴포넌트가 입력 폼인지 확인 const isInputComponent = (comp: any): boolean => { const compType = comp.componentType || comp.type; const webType = comp.webType || comp.componentConfig?.webType; // 명시적으로 제외되는 컴포넌트 타입 if (NON_INPUT_COMPONENT_TYPES.has(compType)) { return false; } // 컴포넌트 타입으로 확인 if (INPUT_COMPONENT_TYPES.has(compType)) { return true; } // 웹타입으로 확인 if (webType && INPUT_WEB_TYPES.has(webType)) { return true; } return false; }; // 키 선택 콤보박스 컴포넌트 interface KeySelectComboboxProps { value?: { keyId: number; langKey: string }; onChange: (value: { keyId: number; langKey: string } | null) => void; langKeys: LangKey[]; isLoading: boolean; } const KeySelectCombobox: React.FC = ({ value, onChange, langKeys, isLoading, }) => { const [open, setOpen] = useState(false); return ( 검색 결과가 없습니다. {/* 연결 해제 옵션 */} {value && ( { onChange(null); setOpen(false); }} className="text-red-600" > 연결 해제 )} {langKeys.map((key) => ( { onChange({ keyId: key.keyId, langKey: key.langKey }); setOpen(false); }} >
{key.langKey} {key.description && ( {key.description} )}
))}
); }; export const MultilangSettingsModal: React.FC = ({ isOpen, onClose, components, onSave, }) => { const [searchText, setSearchText] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedItems, setSelectedItems] = useState>(new Map()); const [extractedLabels, setExtractedLabels] = useState([]); const [langKeys, setLangKeys] = useState([]); const [isLoadingKeys, setIsLoadingKeys] = useState(false); // 테이블별 컬럼 라벨 매핑 (columnName -> displayName) const [columnLabelMap, setColumnLabelMap] = useState>>({}); // 컴포넌트에서 사용되는 테이블명 추출 const getTableNamesFromComponents = useCallback((comps: ComponentData[]): Set => { const tableNames = new Set(); const extractTableName = (comp: any) => { const config = comp.componentConfig; // 1. 최상위 tableName if (comp.tableName) tableNames.add(comp.tableName); // 2. componentConfig 직접 tableName if (config?.tableName) tableNames.add(config.tableName); // 3. 테이블 리스트 컴포넌트 - selectedTable (주요!) if (config?.selectedTable) tableNames.add(config.selectedTable); // 4. 테이블 리스트 컴포넌트 - table 속성 if (config?.table) tableNames.add(config.table); // 5. 분할 패널의 leftPanel/rightPanel if (config?.leftPanel?.tableName) tableNames.add(config.leftPanel.tableName); if (config?.rightPanel?.tableName) tableNames.add(config.rightPanel.tableName); if (config?.leftPanel?.table) tableNames.add(config.leftPanel.table); if (config?.rightPanel?.table) tableNames.add(config.rightPanel.table); if (config?.leftPanel?.selectedTable) tableNames.add(config.leftPanel.selectedTable); if (config?.rightPanel?.selectedTable) tableNames.add(config.rightPanel.selectedTable); // 6. 검색 필터의 tableName if (config?.filter?.tableName) tableNames.add(config.filter.tableName); // 7. properties 안의 tableName if (comp.properties?.tableName) tableNames.add(comp.properties.tableName); if (comp.properties?.selectedTable) tableNames.add(comp.properties.selectedTable); // 자식 컴포넌트 탐색 if (comp.children && Array.isArray(comp.children)) { comp.children.forEach(extractTableName); } }; comps.forEach(extractTableName); return tableNames; }, []); // 이미 로드한 테이블 추적 (무한 루프 방지) const loadedTablesRef = useRef>(new Set()); // 다국어 키 목록 로드 및 테이블별 컬럼 라벨 조회 useEffect(() => { if (!isOpen) return; // 다국어 키 목록 로드 const fetchLangKeys = async () => { setIsLoadingKeys(true); try { const response = await apiClient.get("/multilang/keys"); if (response.data?.success && response.data?.data) { setLangKeys(response.data.data.map((k: any) => ({ keyId: k.keyId, langKey: k.langKey, description: k.description, categoryName: k.categoryName, }))); } } catch (error) { console.error("다국어 키 목록 로드 실패:", error); } finally { setIsLoadingKeys(false); } }; fetchLangKeys(); // 테이블별 컬럼 라벨 조회 const tableNames = getTableNamesFromComponents(components); // 이미 로드한 테이블은 제외 const tablesToLoad = Array.from(tableNames).filter((t) => !loadedTablesRef.current.has(t)); if (tablesToLoad.length > 0) { const fetchColumnLabelsForTables = async () => { const newLabelMap: Record> = {}; for (const tableName of tablesToLoad) { try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); if (response.data?.success && response.data?.data) { const columns = response.data.data.columns || response.data.data; if (Array.isArray(columns)) { newLabelMap[tableName] = {}; columns.forEach((col: any) => { const colName = col.columnName || col.column_name || col.name; const colLabel = col.displayName || col.columnLabel || col.column_label || colName; if (colName) { newLabelMap[tableName][colName] = colLabel; } }); loadedTablesRef.current.add(tableName); } } } catch (error) { console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error); } } if (Object.keys(newLabelMap).length > 0) { setColumnLabelMap((prev) => ({ ...prev, ...newLabelMap })); } }; fetchColumnLabelsForTables(); } }, [isOpen, components, getTableNamesFromComponents]); // 컴포넌트에서 라벨 추출 const extractLabelsFromComponents = useCallback((comps: ComponentData[]): ExtractedLabel[] => { const labels: ExtractedLabel[] = []; const addedLabels = new Set(); const addLabel = ( componentId: string, label: string, type: ExtractedLabel["type"], parentType?: string, parentLabel?: string, langKeyId?: number, langKey?: string ) => { const key = `${componentId}_${type}_${label}`; if (label && label.trim() && !addedLabels.has(key)) { addedLabels.add(key); labels.push({ id: key, componentId, label: label.trim(), type, parentType, parentLabel, langKeyId, langKey, }); } }; const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => { const anyComp = comp as any; const config = anyComp.componentConfig; const compType = anyComp.componentType || anyComp.type; const compLabel = anyComp.label || anyComp.title || compType; // 1. 기본 라벨 - 입력 폼 컴포넌트인 경우에만 추출 if (isInputComponent(anyComp)) { if (anyComp.label && typeof anyComp.label === "string") { addLabel(comp.id, anyComp.label, "label", parentType, parentLabel, anyComp.langKeyId, anyComp.langKey); } } // 2. 제목 if (anyComp.title && typeof anyComp.title === "string") { addLabel(comp.id, anyComp.title, "title", parentType, parentLabel); } // 3. 버튼 텍스트 (componentId에 _button 접미사 추가하여 라벨과 구분) if (config?.text && typeof config.text === "string") { addLabel( `${comp.id}_button`, config.text, "button", parentType, parentLabel, config.langKeyId, config.langKey ); } // 4. placeholder if (anyComp.placeholder && typeof anyComp.placeholder === "string") { addLabel(comp.id, anyComp.placeholder, "placeholder", parentType, parentLabel); } // 5. 테이블 컬럼 - columnLabelMap에서 한글 라벨 조회 const tableName = config?.selectedTable || config?.tableName || config?.table || anyComp.tableName; if (config?.columns && Array.isArray(config.columns)) { config.columns.forEach((col: any, index: number) => { const colName = col.columnName || col.field || col.name; // columnLabelMap에서 한글 라벨 조회, 없으면 displayName 사용 let colLabel = columnLabelMap[tableName]?.[colName] || col.displayName || col.label || colName; if (colLabel && typeof colLabel === "string") { addLabel( `${comp.id}_col_${index}`, colLabel, "column", compType, compLabel, col.langKeyId, col.langKey ); } }); } // 6. 분할 패널 컬럼 - columnLabelMap에서 한글 라벨 조회 const leftTableName = config?.leftPanel?.selectedTable || config?.leftPanel?.tableName || tableName; if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) { config.leftPanel.columns.forEach((col: any, index: number) => { const colName = col.columnName || col.field || col.name; const colLabel = columnLabelMap[leftTableName]?.[colName] || col.displayName || col.label || colName; if (colLabel && typeof colLabel === "string") { addLabel( `${comp.id}_left_col_${index}`, colLabel, "column", compType, `${compLabel} (좌측)`, col.langKeyId, col.langKey ); } }); } const rightTableName = config?.rightPanel?.selectedTable || config?.rightPanel?.tableName || tableName; if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) { config.rightPanel.columns.forEach((col: any, index: number) => { const colName = col.columnName || col.field || col.name; const colLabel = columnLabelMap[rightTableName]?.[colName] || col.displayName || col.label || colName; if (colLabel && typeof colLabel === "string") { addLabel( `${comp.id}_right_col_${index}`, colLabel, "column", compType, `${compLabel} (우측)`, col.langKeyId, col.langKey ); } }); } // 7. 검색 필터 if (config?.filter?.filters && Array.isArray(config.filter.filters)) { config.filter.filters.forEach((filter: any, index: number) => { if (filter.label && typeof filter.label === "string") { addLabel( `${comp.id}_filter_${index}`, filter.label, "filter", compType, compLabel, filter.langKeyId, filter.langKey ); } }); } // 8. 폼 필드 if (config?.fields && Array.isArray(config.fields)) { config.fields.forEach((field: any, index: number) => { if (field.label && typeof field.label === "string") { addLabel( `${comp.id}_field_${index}`, field.label, "field", compType, compLabel, field.langKeyId, field.langKey ); } }); } // 9. 탭 if (config?.tabs && Array.isArray(config.tabs)) { config.tabs.forEach((tab: any, index: number) => { if (tab.label && typeof tab.label === "string") { addLabel( `${comp.id}_tab_${index}`, tab.label, "tab", compType, compLabel, tab.langKeyId, tab.langKey ); } }); } // 10. 액션 버튼 if (config?.actions?.actions && Array.isArray(config.actions.actions)) { config.actions.actions.forEach((action: any, index: number) => { if (action.label && typeof action.label === "string") { addLabel( `${comp.id}_action_${index}`, action.label, "action", compType, compLabel, action.langKeyId, action.langKey ); } }); } // 자식 컴포넌트 재귀 탐색 if (anyComp.children && Array.isArray(anyComp.children)) { anyComp.children.forEach((child: ComponentData) => { extractFromComponent(child, compType, compLabel); }); } }; comps.forEach((comp) => extractFromComponent(comp)); return labels; }, [columnLabelMap]); // 컴포넌트 변경 시 또는 columnLabelMap 로드 후 라벨 재추출 useEffect(() => { if (isOpen && Object.keys(columnLabelMap).length > 0) { const labels = extractLabelsFromComponents(components); setExtractedLabels(labels); // 기존 langKeyId가 있는 항목들 선택 상태로 초기화 const initialSelected = new Map(); labels.forEach((label) => { if (label.langKeyId) { initialSelected.set(label.id, { langKeyId: label.langKeyId, langKey: label.langKey }); } }); setSelectedItems(initialSelected); // 기본적으로 모든 그룹 펼치기 const groupIds = new Set(); labels.forEach((l) => groupIds.add(l.parentType || l.type)); setExpandedGroups(groupIds); } else if (isOpen) { // columnLabelMap 로드 전이라도 일단 컴포넌트 라벨은 표시 const labels = extractLabelsFromComponents(components); setExtractedLabels(labels); const initialSelected = new Map(); labels.forEach((label) => { if (label.langKeyId) { initialSelected.set(label.id, { langKeyId: label.langKeyId, langKey: label.langKey }); } }); setSelectedItems(initialSelected); const groupIds = new Set(); labels.forEach((l) => groupIds.add(l.parentType || l.type)); setExpandedGroups(groupIds); } }, [isOpen, components, extractLabelsFromComponents, columnLabelMap]); // 라벨을 부모 타입별로 그룹화 const groupedLabels = useMemo(() => { const groups: LabelGroup[] = []; const groupMap = new Map(); extractedLabels.forEach((label) => { const groupKey = label.parentType || label.type; if (!groupMap.has(groupKey)) { groupMap.set(groupKey, []); } groupMap.get(groupKey)!.push(label); }); groupMap.forEach((items, key) => { // 검색 필터링 const filteredItems = searchText ? items.filter( (item) => item.label.toLowerCase().includes(searchText.toLowerCase()) || item.langKey?.toLowerCase().includes(searchText.toLowerCase()) ) : items; if (filteredItems.length > 0) { groups.push({ id: key, label: items[0].parentLabel || getTypeLabel(key), type: key, icon: getTypeIcon(key), items: filteredItems, isExpanded: expandedGroups.has(key), }); } }); return groups; }, [extractedLabels, searchText, expandedGroups]); // 그룹 펼침/접기 토글 const toggleGroup = (groupId: string) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }; // 키 선택 핸들러 const handleKeySelect = (itemId: string, key: { keyId: number; langKey: string } | null) => { setSelectedItems((prev) => { const next = new Map(prev); if (key) { next.set(itemId, { langKeyId: key.keyId, langKey: key.langKey }); } else { next.delete(itemId); } return next; }); }; // 연결된 키 개수 const connectedCount = selectedItems.size; const totalCount = extractedLabels.length; return ( !open && onClose()}> 다국어 설정 {connectedCount} / {totalCount} 연결됨 {/* 검색 */}
setSearchText(e.target.value)} className="pl-10" />
{/* 라벨 목록 */}
{groupedLabels.length === 0 ? (
{searchText ? "검색 결과가 없습니다." : "다국어 대상이 없습니다."}
) : ( groupedLabels.map((group) => (
{/* 그룹 헤더 */} {/* 그룹 아이템 */} {group.isExpanded && (
{group.items.map((item) => { const isConnected = selectedItems.has(item.id); const connectedKey = selectedItems.get(item.id); return (
{/* 상태 아이콘 */}
{isConnected ? ( ) : ( )}
{/* 타입 배지 */} {getTypeLabel(item.type)} {/* 라벨 텍스트 */} {item.label} {/* 키 선택 콤보박스 */} handleKeySelect(item.id, key)} langKeys={langKeys} isLoading={isLoadingKeys} />
); })}
)}
)) )}
); }; export default MultilangSettingsModal;