From 16c9c71a232d12b695d798189438cec56025ace5 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 14 Jan 2026 16:33:22 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=88=98=EC=A0=95=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/modals/MultilangSettingsModal.tsx | 519 ++++++++++++++---- frontend/contexts/ScreenMultiLangContext.tsx | 41 ++ .../SplitPanelLayoutComponent.tsx | 59 +- .../components/split-panel-layout/types.ts | 8 + frontend/lib/utils/multilangLabelExtractor.ts | 167 +++++- 5 files changed, 668 insertions(+), 126 deletions(-) diff --git a/frontend/components/screen/modals/MultilangSettingsModal.tsx b/frontend/components/screen/modals/MultilangSettingsModal.tsx index ee237ff6..1c2ba551 100644 --- a/frontend/components/screen/modals/MultilangSettingsModal.tsx +++ b/frontend/components/screen/modals/MultilangSettingsModal.tsx @@ -53,6 +53,14 @@ interface LangKey { categoryName?: string; } +// 번역 텍스트 타입 +interface LangText { + langCode: string; + langName: string; + langNative: string; + text: string; +} + // 입력 가능한 폼 컴포넌트 타입 목록 const INPUT_COMPONENT_TYPES = new Set([ "text-input", @@ -508,7 +516,32 @@ export const MultilangSettingsModal: React.FC = ({ }); } - // 6. 분할 패널 컬럼 - columnLabelMap에서 한글 라벨 조회 + // 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) => { @@ -546,6 +579,56 @@ export const MultilangSettingsModal: React.FC = ({ }); } + // 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) => { @@ -732,9 +815,160 @@ export const MultilangSettingsModal: React.FC = ({ const connectedCount = selectedItems.size; const totalCount = extractedLabels.length; + // 선택된 라벨 항목 + const [selectedLabelItem, setSelectedLabelItem] = useState(null); + + // 선택된 라벨의 번역 텍스트 로드 + const [isLoadingTranslations, setIsLoadingTranslations] = useState(false); + + // 모든 항목의 편집된 번역을 저장하는 맵 (itemId -> langCode -> text) + const [allEditedTranslations, setAllEditedTranslations] = useState>>({}); + + // 현재 선택된 항목의 편집된 번역 + const currentTranslations = selectedLabelItem ? (allEditedTranslations[selectedLabelItem.id] || {}) : {}; + + // 저장 중 상태 + const [isSaving, setIsSaving] = useState(false); + + // 활성화된 언어 목록 + const [activeLanguages, setActiveLanguages] = useState>([]); + + // 언어 목록 로드 + 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, + })); + } + } catch (error) { + console.error("번역 텍스트 로드 실패:", error); + } finally { + setIsLoadingTranslations(false); + } + }; + + loadTranslations(); + }, [selectedLabelItem, selectedItems, activeLanguages, allEditedTranslations]); + + // 현재 항목의 번역 텍스트 변경 + const handleTranslationChange = (langCode: string, text: string) => { + if (!selectedLabelItem) return; + + setAllEditedTranslations((prev) => ({ + ...prev, + [selectedLabelItem.id]: { + ...(prev[selectedLabelItem.id] || {}), + [langCode]: text, + }, + })); + }; + + // 모든 번역 텍스트 저장 (최종 저장 시) + 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({}); + } + }, [isOpen]); + return ( !open && onClose()}> - + @@ -745,117 +979,214 @@ export const MultilangSettingsModal: React.FC = ({ - {/* 검색 */} -
- - setSearchText(e.target.value)} - className="pl-10" - /> -
+ {/* 좌우 분할 레이아웃 */} +
+ {/* 왼쪽: 라벨 목록 */} +
+ {/* 검색 */} +
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
- {/* 라벨 목록 */} - -
- {groupedLabels.length === 0 ? ( -
- {searchText ? "검색 결과가 없습니다." : "다국어 대상이 없습니다."} + {/* 라벨 목록 */} + +
+ {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; + + 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" + : isConnected + ? "border-green-200 bg-green-50 hover:bg-green-100" + : "border-gray-200 bg-white hover:bg-gray-50" + )} + > + {/* 상태 아이콘 */} +
+ {isConnected ? ( + + ) : ( + + )} +
+ + {/* 타입 배지 */} + + {getTypeLabel(item.type)} + + + {/* 라벨 텍스트 */} + {item.label} +
+ ); + })} +
+ )} +
+ )) + )}
- ) : ( - groupedLabels.map((group) => ( -
- {/* 그룹 헤더 */} - + +
- {/* 그룹 아이템 */} - {group.isExpanded && ( -
- {group.items.map((item) => { - const isConnected = selectedItems.has(item.id); - const connectedKey = selectedItems.get(item.id); + {/* 오른쪽: 선택된 항목 상세 및 번역 편집 */} +
+ {selectedLabelItem ? ( + <> + {/* 선택된 항목 정보 */} +
+
+ {getTypeLabel(selectedLabelItem.type)} + {selectedLabelItem.label} +
- return ( -
- {/* 상태 아이콘 */} -
- {isConnected ? ( - - ) : ( - - )} + {/* 키 선택 */} +
+ 다국어 키: + 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} +
- - {/* 타입 배지 */} - - {getTypeLabel(item.type)} - - - {/* 라벨 텍스트 */} - {item.label} - - {/* 키 선택 콤보박스 */} - handleKeySelect(item.id, key)} - langKeys={langKeys} - isLoading={isLoadingKeys} + handleTranslationChange(lang.langCode, e.target.value)} + placeholder={`${lang.langName} 번역 입력...`} + className="h-9 text-sm" />
- ); - })} -
+ ))} +
+ )}
- )) + + ) : ( +
+ +

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

+

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

+
)}
- +
diff --git a/frontend/contexts/ScreenMultiLangContext.tsx b/frontend/contexts/ScreenMultiLangContext.tsx index eb938927..296a0ab6 100644 --- a/frontend/contexts/ScreenMultiLangContext.tsx +++ b/frontend/contexts/ScreenMultiLangContext.tsx @@ -58,6 +58,47 @@ export const ScreenMultiLangProvider: React.FC = ( } }); } + // 분할패널 좌측/우측 제목 langKey 수집 + const config = (comp as any).componentConfig; + if (config?.leftPanel?.langKey) { + keys.push(config.leftPanel.langKey); + } + if (config?.rightPanel?.langKey) { + keys.push(config.rightPanel.langKey); + } + // 분할패널 좌측/우측 컬럼 langKey 수집 + if (config?.leftPanel?.columns) { + config.leftPanel.columns.forEach((col: any) => { + if (col.langKey) { + keys.push(col.langKey); + } + }); + } + if (config?.rightPanel?.columns) { + config.rightPanel.columns.forEach((col: any) => { + if (col.langKey) { + keys.push(col.langKey); + } + }); + } + // 추가 탭 langKey 수집 + if (config?.additionalTabs) { + config.additionalTabs.forEach((tab: any) => { + if (tab.langKey) { + keys.push(tab.langKey); + } + if (tab.titleLangKey) { + keys.push(tab.titleLangKey); + } + if (tab.columns) { + tab.columns.forEach((col: any) => { + if (col.langKey) { + keys.push(col.langKey); + } + }); + } + }); + } // 자식 컴포넌트 재귀 처리 if ((comp as any).children) { collectLangKeys((comp as any).children); diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 58af3756..a84c90fd 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -38,6 +38,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -58,6 +59,8 @@ export const SplitPanelLayoutComponent: React.FC const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) const companyCode = (props as any).companyCode as string | undefined; + // 🌐 다국어 컨텍스트 + const { getTranslatedText } = useScreenMultiLang(); // 기본 설정값 const splitRatio = componentConfig.splitRatio || 30; @@ -2559,7 +2562,7 @@ export const SplitPanelLayoutComponent: React.FC >
- {componentConfig.leftPanel?.title || "좌측 패널"} + {getTranslatedText(componentConfig.leftPanel?.langKey, componentConfig.leftPanel?.title || "좌측 패널")} {!isDesignMode && componentConfig.leftPanel?.showAdd && (