"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, Circle, CircleDot, CheckCircle2, } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Progress } from "@/components/ui/progress"; 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; } // 번역 텍스트 타입 interface LangText { langCode: string; langName: string; langNative: string; text: 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; } // 번역 상태 타입 type TranslationStatus = "complete" | "partial" | "none"; // 번역 필터 타입 type TranslationFilter = "all" | "complete" | "incomplete"; // 그룹화된 라벨 타입 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 [translationFilter, setTranslationFilter] = useState("all"); // 항목별 번역 상태 캐시 (itemId -> status) const [translationStatusCache, setTranslationStatusCache] = useState>({}); // 선택된 라벨 항목 const [selectedLabelItem, setSelectedLabelItem] = useState(null); // 선택된 라벨의 번역 텍스트 로드 const [isLoadingTranslations, setIsLoadingTranslations] = useState(false); // 모든 항목의 편집된 번역을 저장하는 맵 (itemId -> langCode -> text) const [allEditedTranslations, setAllEditedTranslations] = useState>>({}); // 저장 중 상태 const [isSaving, setIsSaving] = useState(false); // 활성화된 언어 목록 const [activeLanguages, setActiveLanguages] = 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에서 한글 라벨 조회 // 6-1. 좌측 패널 제목 if (config?.leftPanel?.title && typeof config.leftPanel.title === "string") { addLabel( `${comp.id}_left_title`, config.leftPanel.title, "title", compType, `${compLabel} (좌측)`, config.leftPanel.langKeyId, config.leftPanel.langKey ); } // 6-2. 우측 패널 제목 if (config?.rightPanel?.title && typeof config.rightPanel.title === "string") { addLabel( `${comp.id}_right_title`, config.rightPanel.title, "title", compType, `${compLabel} (우측)`, config.rightPanel.langKeyId, config.rightPanel.langKey ); } // 6-3. 좌측 패널 컬럼 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 ); } }); } // 6-5. 추가 탭 (additionalTabs) 제목 및 컬럼 - rightPanel.additionalTabs 확인 const additionalTabs = config?.rightPanel?.additionalTabs || config?.additionalTabs; if (additionalTabs && Array.isArray(additionalTabs)) { additionalTabs.forEach((tab: any, tabIndex: number) => { // 탭 라벨 if (tab.label && typeof tab.label === "string") { addLabel( `${comp.id}_addtab_${tabIndex}_label`, tab.label, "tab", compType, compLabel, tab.langKeyId, tab.langKey ); } // 탭 제목 if (tab.title && typeof tab.title === "string") { addLabel( `${comp.id}_addtab_${tabIndex}_title`, tab.title, "title", compType, `${compLabel} (탭: ${tab.label || tabIndex})`, tab.titleLangKeyId, tab.titleLangKey ); } // 탭 컬럼 const tabTableName = tab.tableName || tab.selectedTable || rightTableName; if (tab.columns && Array.isArray(tab.columns)) { tab.columns.forEach((col: any, colIndex: number) => { const colName = col.columnName || col.field || col.name; const colLabel = columnLabelMap[tabTableName]?.[colName] || col.displayName || col.label || colName; if (colLabel && typeof colLabel === "string") { addLabel( `${comp.id}_addtab_${tabIndex}_col_${colIndex}`, colLabel, "column", compType, `${compLabel} (탭: ${tab.label || tabIndex})`, 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]); // 모달 열릴 때 연결된 키들의 번역 상태 일괄 조회 useEffect(() => { const loadAllTranslationStatuses = async () => { if (!isOpen || activeLanguages.length === 0 || extractedLabels.length === 0) return; // 연결된 키가 있는 항목들만 필터링 const itemsWithKeys = extractedLabels.filter((label) => label.langKeyId); if (itemsWithKeys.length === 0) return; const newTranslations: Record> = {}; const newStatusCache: Record = {}; // 각 키의 번역 상태 조회 await Promise.all( itemsWithKeys.map(async (label) => { try { const response = await apiClient.get(`/multilang/keys/${label.langKeyId}/texts`); if (response.data?.success && response.data?.data) { const textsMap: Record = {}; response.data.data.forEach((t: any) => { textsMap[t.langCode || t.lang_code] = t.langText || t.lang_text || ""; }); // 모든 활성 언어에 대해 번역 텍스트 생성 const loadedTranslations = activeLanguages.reduce((acc, lang) => { acc[lang.langCode] = textsMap[lang.langCode] || ""; return acc; }, {} as Record); newTranslations[label.id] = loadedTranslations; // 번역 상태 계산 const filledCount = activeLanguages.filter( (lang) => loadedTranslations[lang.langCode] && loadedTranslations[lang.langCode].trim() !== "" ).length; if (filledCount === activeLanguages.length) { newStatusCache[label.id] = "complete"; } else if (filledCount > 0) { newStatusCache[label.id] = "partial"; } else { newStatusCache[label.id] = "none"; } } } catch (error) { console.error(`번역 상태 조회 실패 (keyId: ${label.langKeyId}):`, error); } }) ); // 상태 업데이트 if (Object.keys(newTranslations).length > 0) { setAllEditedTranslations((prev) => ({ ...prev, ...newTranslations })); } if (Object.keys(newStatusCache).length > 0) { setTranslationStatusCache((prev) => ({ ...prev, ...newStatusCache })); } }; loadAllTranslationStatuses(); }, [isOpen, activeLanguages, extractedLabels]); // 번역 상태 계산 함수 const getTranslationStatus = useCallback( (itemId: string): TranslationStatus => { // 캐시에 있으면 반환 if (translationStatusCache[itemId]) { return translationStatusCache[itemId]; } const connectedKey = selectedItems.get(itemId); if (!connectedKey?.langKeyId) { return "none"; // 키 미연결 } const translations = allEditedTranslations[itemId]; if (!translations) { return "none"; // 번역 정보 없음 } const filledCount = activeLanguages.filter( (lang) => translations[lang.langCode] && translations[lang.langCode].trim() !== "" ).length; if (filledCount === 0) { return "none"; } else if (filledCount === activeLanguages.length) { return "complete"; } else { return "partial"; } }, [selectedItems, allEditedTranslations, activeLanguages, translationStatusCache] ); // 번역 상태 아이콘 컴포넌트 const TranslationStatusIcon: React.FC<{ status: TranslationStatus }> = ({ status }) => { switch (status) { case "complete": return ; case "partial": return ; case "none": default: return ; } }; // 번역 상태별 통계 계산 const translationStats = useMemo(() => { let complete = 0; let partial = 0; let none = 0; extractedLabels.forEach((label) => { const status = getTranslationStatus(label.id); if (status === "complete") complete++; else if (status === "partial") partial++; else none++; }); return { complete, partial, none, total: extractedLabels.length }; }, [extractedLabels, getTranslationStatus]); // 진행률 계산 (완료 + 부분완료*0.5) const progressPercentage = useMemo(() => { if (translationStats.total === 0) return 0; const score = translationStats.complete + translationStats.partial * 0.5; return Math.round((score / translationStats.total) * 100); }, [translationStats]); // 라벨을 부모 타입별로 그룹화 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) => { // 검색 필터링 let filteredItems = searchText ? items.filter( (item) => item.label.toLowerCase().includes(searchText.toLowerCase()) || item.langKey?.toLowerCase().includes(searchText.toLowerCase()) ) : items; // 번역 상태 필터링 if (translationFilter !== "all") { filteredItems = filteredItems.filter((item) => { const status = getTranslationStatus(item.id); if (translationFilter === "complete") { return status === "complete"; } else if (translationFilter === "incomplete") { return status === "partial" || status === "none"; } return true; }); } 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, translationFilter, getTranslationStatus]); // 그룹 펼침/접기 토글 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; // 현재 선택된 항목의 편집된 번역 const currentTranslations = selectedLabelItem ? (allEditedTranslations[selectedLabelItem.id] || {}) : {}; // 언어 목록 로드 useEffect(() => { const loadLanguages = async () => { try { const response = await apiClient.get("/multilang/languages"); if (response.data?.success && Array.isArray(response.data.data)) { const activeLangs = response.data.data .filter((lang: any) => (lang.isActive || lang.is_active) === "Y") .map((lang: any) => ({ langCode: lang.langCode || lang.lang_code, langName: lang.langName || lang.lang_name, langNative: lang.langNative || lang.lang_native, })); setActiveLanguages(activeLangs); } } catch (error) { console.error("언어 목록 로드 실패:", error); } }; if (isOpen) { loadLanguages(); } }, [isOpen]); // 선택된 라벨의 번역 텍스트 로드 useEffect(() => { const loadTranslations = async () => { if (!selectedLabelItem) { return; } // 언어 목록이 아직 로드되지 않았으면 대기 if (activeLanguages.length === 0) { return; } // 이미 이 항목의 번역을 로드한 적이 있으면 스킵 if (allEditedTranslations[selectedLabelItem.id]) { return; } const connectedKey = selectedItems.get(selectedLabelItem.id); if (!connectedKey?.langKeyId) { // 키가 연결되지 않은 경우 빈 번역으로 초기화 const defaultTranslations = activeLanguages.reduce((acc, lang) => { acc[lang.langCode] = lang.langCode === "KR" ? selectedLabelItem.label : ""; return acc; }, {} as Record); setAllEditedTranslations((prev) => ({ ...prev, [selectedLabelItem.id]: defaultTranslations, })); return; } setIsLoadingTranslations(true); try { const response = await apiClient.get(`/multilang/keys/${connectedKey.langKeyId}/texts`); if (response.data?.success && response.data?.data) { const textsMap: Record = {}; response.data.data.forEach((t: any) => { textsMap[t.langCode || t.lang_code] = t.langText || t.lang_text || ""; }); // 모든 활성 언어에 대해 번역 텍스트 생성 const loadedTranslations = activeLanguages.reduce((acc, lang) => { acc[lang.langCode] = textsMap[lang.langCode] || ""; return acc; }, {} as Record); setAllEditedTranslations((prev) => ({ ...prev, [selectedLabelItem.id]: loadedTranslations, })); // 번역 상태 캐시 업데이트 const filledCount = activeLanguages.filter( (lang) => loadedTranslations[lang.langCode] && loadedTranslations[lang.langCode].trim() !== "" ).length; let status: TranslationStatus = "none"; if (filledCount === activeLanguages.length) { status = "complete"; } else if (filledCount > 0) { status = "partial"; } setTranslationStatusCache((prev) => ({ ...prev, [selectedLabelItem.id]: status, })); } } catch (error) { console.error("번역 텍스트 로드 실패:", error); } finally { setIsLoadingTranslations(false); } }; loadTranslations(); }, [selectedLabelItem, selectedItems, activeLanguages, allEditedTranslations]); // 현재 항목의 번역 텍스트 변경 const handleTranslationChange = (langCode: string, text: string) => { if (!selectedLabelItem) return; const updatedTranslations = { ...(allEditedTranslations[selectedLabelItem.id] || {}), [langCode]: text, }; setAllEditedTranslations((prev) => ({ ...prev, [selectedLabelItem.id]: updatedTranslations, })); // 번역 상태 캐시 업데이트 const filledCount = activeLanguages.filter( (lang) => { const val = lang.langCode === langCode ? text : updatedTranslations[lang.langCode]; return val && val.trim() !== ""; } ).length; let status: TranslationStatus = "none"; if (filledCount === activeLanguages.length) { status = "complete"; } else if (filledCount > 0) { status = "partial"; } setTranslationStatusCache((prev) => ({ ...prev, [selectedLabelItem.id]: status, })); }; // 모든 번역 텍스트 저장 (최종 저장 시) const handleSaveAllTranslations = async () => { const savePromises: Promise[] = []; // 키가 연결된 항목들의 번역만 저장 for (const [itemId, translations] of Object.entries(allEditedTranslations)) { const connectedKey = selectedItems.get(itemId); if (!connectedKey?.langKeyId) continue; const texts = activeLanguages.map((lang) => ({ langCode: lang.langCode, langText: translations[lang.langCode] || "", })); savePromises.push( apiClient.post(`/multilang/keys/${connectedKey.langKeyId}/texts`, { texts }) .then(() => {}) .catch((error) => { console.error(`번역 저장 실패 (keyId: ${connectedKey.langKeyId}):`, error); }) ); } await Promise.all(savePromises); }; // 모달 닫힐 때 선택 초기화 useEffect(() => { if (!isOpen) { setSelectedLabelItem(null); setAllEditedTranslations({}); setTranslationFilter("all"); setTranslationStatusCache({}); } }, [isOpen]); 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 isSelected = selectedLabelItem?.id === item.id; const translationStatus = getTranslationStatus(item.id); return (
setSelectedLabelItem(item)} className={cn( "flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors", isSelected ? "border-primary bg-primary/10 ring-1 ring-primary" : translationStatus === "complete" ? "border-green-200 bg-green-50 hover:bg-green-100" : translationStatus === "partial" ? "border-yellow-200 bg-yellow-50 hover:bg-yellow-100" : "border-gray-200 bg-white hover:bg-gray-50" )} > {/* 번역 상태 아이콘 */}
{/* 타입 배지 */} {getTypeLabel(item.type)} {/* 라벨 텍스트 */} {item.label}
); })}
)}
)) )}
{/* 오른쪽: 선택된 항목 상세 및 번역 편집 */}
{selectedLabelItem ? ( <> {/* 선택된 항목 정보 */}
{getTypeLabel(selectedLabelItem.type)} {selectedLabelItem.label}
{/* 키 선택 */}
다국어 키: handleKeySelect(selectedLabelItem.id, key)} langKeys={langKeys} isLoading={isLoadingKeys} />
{/* 번역 텍스트 편집 */}

번역 텍스트

{isLoadingTranslations || activeLanguages.length === 0 ? (
) : !selectedItems.get(selectedLabelItem.id)?.langKeyId ? (
먼저 다국어 키를 선택해주세요
) : (
{activeLanguages.map((lang) => (
{lang.langCode} {lang.langNative || lang.langName}
handleTranslationChange(lang.langCode, e.target.value)} placeholder={`${lang.langName} 번역 입력...`} className="h-9 text-sm" />
))}
)}
) : (

좌측에서 항목을 선택하세요

선택한 항목의 다국어 설정을 할 수 있습니다

)}
{/* 하단 진행률 표시 */}
번역 현황
완료 {translationStats.complete} 부분 {translationStats.partial} 미완료 {translationStats.none}
{progressPercentage}%
); }; export default MultilangSettingsModal;