From b5c2e854961620e45d7e5a302cfd983252f8c27d Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 14 Jan 2026 15:33:57 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 35 +- backend-node/src/services/multilangService.ts | 58 +- .../app/(main)/screens/[screenId]/page.tsx | 11 +- frontend/components/layout/ProfileModal.tsx | 62 ++- .../screen/InteractiveScreenViewer.tsx | 78 ++- .../screen/RealtimePreviewDynamic.tsx | 4 + frontend/contexts/ScreenMultiLangContext.tsx | 141 +++++ .../button-primary/ButtonPrimaryComponent.tsx | 7 +- .../table-list/SingleTableWithSticky.tsx | 516 +++++++++--------- .../table-list/TableListComponent.tsx | 18 +- 10 files changed, 657 insertions(+), 273 deletions(-) create mode 100644 frontend/contexts/ScreenMultiLangContext.tsx diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 78ea320b..ce7b9c7f 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -553,10 +553,24 @@ export const setUserLocale = async ( const { locale } = req.body; - if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) { + if (!locale) { res.status(400).json({ success: false, - message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)", + message: "로케일이 필요합니다.", + }); + return; + } + + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + success: false, + message: `유효하지 않은 로케일입니다: ${locale}`, }); return; } @@ -3103,6 +3117,23 @@ export const updateProfile = async ( } if (locale !== undefined) { + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + result: false, + error: { + code: "INVALID_LOCALE", + details: `유효하지 않은 로케일입니다: ${locale}`, + }, + }); + return; + } + updateFields.push(`locale = $${paramIndex}`); updateValues.push(locale); paramIndex++; diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index 06daf725..fc765d89 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -1189,6 +1189,7 @@ export class MultiLangService { /** * 배치 번역 조회 (회사별 우선순위 적용) * 우선순위: 회사별 키 > 공통 키(*) + * 폴백: 요청 언어 번역이 없으면 KR 번역 사용 */ async getBatchTranslations( params: BatchTranslationRequest @@ -1233,16 +1234,10 @@ export class MultiLangService { ); const result: Record = {}; - - // 기본값으로 모든 키 설정 - params.langKeys.forEach((key) => { - result[key] = key; - }); + const processedKeys = new Set(); // 우선순위 기반으로 번역 적용 // priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음 - const processedKeys = new Set(); - translations.forEach((translation) => { const langKey = translation.lang_key; if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) { @@ -1251,6 +1246,55 @@ export class MultiLangService { } }); + // 번역이 없는 키들에 대해 KR 폴백 조회 (요청 언어가 KR이 아닌 경우) + const missingKeys = params.langKeys.filter((key) => !processedKeys.has(key)); + + if (missingKeys.length > 0 && params.userLang !== "KR") { + logger.info("KR 폴백 번역 조회 시작", { missingCount: missingKeys.length }); + + const fallbackPlaceholders = missingKeys.map((_, i) => `$${i + 3}`).join(", "); + const fallbackTranslations = await query<{ + lang_text: string; + lang_key: string; + company_code: string; + priority: number; + }>( + `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, + CASE WHEN mlkm.company_code = $2 THEN 1 ELSE 2 END as priority + FROM multi_lang_text mlt + INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id + WHERE mlt.lang_code = 'KR' + AND mlt.is_active = $1 + AND mlkm.lang_key IN (${fallbackPlaceholders}) + AND mlkm.company_code IN ($2, '*') + AND mlkm.is_active = $1 + ORDER BY mlkm.lang_key ASC, priority ASC`, + ["Y", params.companyCode, ...missingKeys] + ); + + // KR 폴백 적용 + const fallbackProcessed = new Set(); + fallbackTranslations.forEach((translation) => { + const langKey = translation.lang_key; + if (!result[langKey] && !fallbackProcessed.has(langKey)) { + result[langKey] = translation.lang_text; + fallbackProcessed.add(langKey); + } + }); + + logger.info("KR 폴백 번역 조회 완료", { + missingCount: missingKeys.length, + foundFallback: fallbackTranslations.length, + }); + } + + // 여전히 없는 키는 키 자체를 반환 (최후의 폴백) + params.langKeys.forEach((key) => { + if (!result[key]) { + result[key] = key; + } + }); + logger.info("배치 번역 조회 완료", { totalKeys: params.langKeys.length, foundTranslations: translations.length, diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 4d7b8e7c..29288163 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 +import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 function ScreenViewPage() { const params = useParams(); @@ -345,9 +346,10 @@ function ScreenViewPage() { {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( -
+
); })()} -
+
+ ) : ( // 빈 화면일 때
diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index ad23acb4..a74501e0 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Dialog, DialogContent, @@ -15,6 +16,14 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react"; import { ProfileFormData } from "@/types/profile"; import { Separator } from "@/components/ui/separator"; import { VehicleRegisterData } from "@/lib/api/driver"; +import { apiClient } from "@/lib/api/client"; + +// 언어 정보 타입 +interface LanguageInfo { + langCode: string; + langName: string; + langNative: string; +} // 운전자 정보 타입 export interface DriverInfo { @@ -148,6 +157,46 @@ export function ProfileModal({ onSave, onAlertClose, }: ProfileModalProps) { + // 언어 목록 상태 + const [languages, setLanguages] = useState([]); + + // 언어 목록 로드 + useEffect(() => { + const loadLanguages = async () => { + try { + const response = await apiClient.get("/multilang/languages"); + if (response.data?.success && response.data?.data) { + // is_active가 'Y'인 언어만 필터링하고 정렬 + const activeLanguages = response.data.data + .filter((lang: any) => lang.isActive === "Y" || 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, + })) + .sort((a: LanguageInfo, b: LanguageInfo) => { + // KR을 먼저 표시 + if (a.langCode === "KR") return -1; + if (b.langCode === "KR") return 1; + return a.langCode.localeCompare(b.langCode); + }); + setLanguages(activeLanguages); + } + } catch (error) { + console.error("언어 목록 로드 실패:", error); + // 기본값 설정 + setLanguages([ + { langCode: "KR", langName: "Korean", langNative: "한국어" }, + { langCode: "US", langName: "English", langNative: "English" }, + ]); + } + }; + + if (isOpen) { + loadLanguages(); + } + }, [isOpen]); + // 차량 상태 한글 변환 const getStatusLabel = (status: string | null) => { switch (status) { @@ -293,10 +342,15 @@ export function ProfileModal({ - 한국어 (KR) - English (US) - 日本語 (JP) - 中文 (CN) + {languages.length > 0 ? ( + languages.map((lang) => ( + + {lang.langNative} ({lang.langCode}) + + )) + ) : ( + 한국어 (KR) + )}
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 9a0ffa8d..7c120d6d 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -48,6 +48,7 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { buildGridClasses } from "@/lib/constants/columnSpans"; import { cn } from "@/lib/utils"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; +import { useMultiLang } from "@/hooks/useMultiLang"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; @@ -187,9 +188,63 @@ export const InteractiveScreenViewer: React.FC = ( const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기 + const { userLang } = useMultiLang(); // 다국어 훅 const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); + // 다국어 번역 상태 (langKeyId가 있는 컴포넌트들의 번역 텍스트) + const [translations, setTranslations] = useState>({}); + + // 다국어 키 수집 및 번역 로드 + useEffect(() => { + const loadTranslations = async () => { + // 모든 컴포넌트에서 langKey 수집 + const langKeysToFetch: string[] = []; + + const collectLangKeys = (comps: ComponentData[]) => { + comps.forEach((comp) => { + // 컴포넌트 라벨의 langKey + if ((comp as any).langKey) { + langKeysToFetch.push((comp as any).langKey); + } + // componentConfig 내의 langKey (버튼 텍스트 등) + if ((comp as any).componentConfig?.langKey) { + langKeysToFetch.push((comp as any).componentConfig.langKey); + } + // 자식 컴포넌트 재귀 처리 + if ((comp as any).children) { + collectLangKeys((comp as any).children); + } + }); + }; + + collectLangKeys(allComponents); + + // langKey가 있으면 배치 조회 + if (langKeysToFetch.length > 0 && userLang) { + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.post("/multilang/batch", { + langKeys: [...new Set(langKeysToFetch)], // 중복 제거 + }, { + params: { + userLang, + companyCode: user?.companyCode || "*", + }, + }); + + if (response.data?.success && response.data?.data) { + setTranslations(response.data.data); + } + } catch (error) { + console.error("다국어 번역 로드 실패:", error); + } + } + }; + + loadTranslations(); + }, [allComponents, userLang, user?.companyCode]); + // 팝업 화면 상태 const [popupScreen, setPopupScreen] = useState<{ screenId: number; @@ -568,9 +623,13 @@ export const InteractiveScreenViewer: React.FC = ( ); } - const { widgetType, label, placeholder, required, readonly, columnName } = comp; + const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; + + // 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용) + const compLangKey = (comp as any).langKey; + const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel; // 스타일 적용 const applyStyles = (element: React.ReactElement) => { @@ -1877,6 +1936,12 @@ export const InteractiveScreenViewer: React.FC = ( } }; + // 버튼 텍스트 다국어 적용 (componentConfig.langKey 확인) + const buttonLangKey = (widget as any).componentConfig?.langKey; + const buttonText = buttonLangKey && translations[buttonLangKey] + ? translations[buttonLangKey] + : (widget as any).componentConfig?.text || label || "버튼"; + return applyStyles( ); } @@ -2035,7 +2100,10 @@ export const InteractiveScreenViewer: React.FC = ( (component.label || component.style?.labelText) && !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 - const labelText = component.style?.labelText || component.label || ""; + // 다국어 라벨 텍스트 결정 (langKey가 있으면 번역 텍스트 사용) + const langKey = (component as any).langKey; + const originalLabelText = component.style?.labelText || component.label || ""; + const labelText = langKey && translations[langKey] ? translations[langKey] : originalLabelText; // 라벨 표시 여부 로그 (디버깅용) if (component.type === "widget") { @@ -2044,6 +2112,8 @@ export const InteractiveScreenViewer: React.FC = ( hideLabel, shouldShowLabel, labelText, + langKey, + hasTranslation: !!translations[langKey], }); } diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index a2537ce0..6e401d6d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -17,6 +17,7 @@ import { File, } from "lucide-react"; import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // 컴포넌트 렌더러들 자동 등록 import "@/lib/registry/components"; @@ -129,6 +130,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onFormDataChange, onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백 }) => { + // 🆕 화면 다국어 컨텍스트 + const { getTranslatedText } = useScreenMultiLang(); + const [actualHeight, setActualHeight] = React.useState(null); const contentRef = React.useRef(null); const lastUpdatedHeight = React.useRef(null); diff --git a/frontend/contexts/ScreenMultiLangContext.tsx b/frontend/contexts/ScreenMultiLangContext.tsx new file mode 100644 index 00000000..eb938927 --- /dev/null +++ b/frontend/contexts/ScreenMultiLangContext.tsx @@ -0,0 +1,141 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from "react"; +import { apiClient } from "@/lib/api/client"; +import { useMultiLang } from "@/hooks/useMultiLang"; +import { ComponentData } from "@/types/screen"; + +interface ScreenMultiLangContextValue { + translations: Record; + loading: boolean; + getTranslatedText: (langKey: string | undefined, fallback: string) => string; +} + +const ScreenMultiLangContext = createContext(null); + +interface ScreenMultiLangProviderProps { + children: ReactNode; + components: ComponentData[]; + companyCode?: string; +} + +/** + * 화면 컴포넌트들의 다국어 번역을 제공하는 Provider + * 모든 langKey를 수집하여 한 번에 배치 조회하고, 하위 컴포넌트에서 번역 텍스트를 사용할 수 있게 함 + */ +export const ScreenMultiLangProvider: React.FC = ({ + children, + components, + companyCode = "*", +}) => { + const { userLang } = useMultiLang(); + const [translations, setTranslations] = useState>({}); + const [loading, setLoading] = useState(false); + + // 모든 컴포넌트에서 langKey 수집 + const langKeys = useMemo(() => { + const keys: string[] = []; + + const collectLangKeys = (comps: ComponentData[]) => { + comps.forEach((comp) => { + // 컴포넌트 라벨의 langKey + if ((comp as any).langKey) { + keys.push((comp as any).langKey); + } + // componentConfig 내의 langKey (버튼 텍스트 등) + if ((comp as any).componentConfig?.langKey) { + keys.push((comp as any).componentConfig.langKey); + } + // properties 내의 langKey (레거시) + if ((comp as any).properties?.langKey) { + keys.push((comp as any).properties.langKey); + } + // 테이블 리스트 컬럼의 langKey 수집 + if ((comp as any).componentConfig?.columns) { + (comp as any).componentConfig.columns.forEach((col: any) => { + if (col.langKey) { + keys.push(col.langKey); + } + }); + } + // 자식 컴포넌트 재귀 처리 + if ((comp as any).children) { + collectLangKeys((comp as any).children); + } + }); + }; + + collectLangKeys(components); + return [...new Set(keys)]; // 중복 제거 + }, [components]); + + // langKey가 있으면 배치 조회 + useEffect(() => { + const loadTranslations = async () => { + if (langKeys.length === 0 || !userLang) { + return; + } + + setLoading(true); + try { + console.log("🌐 [ScreenMultiLang] 다국어 배치 로드:", { langKeys: langKeys.length, userLang, companyCode }); + + const response = await apiClient.post( + "/multilang/batch", + { langKeys }, + { + params: { + userLang, + companyCode, + }, + } + ); + + if (response.data?.success && response.data?.data) { + console.log("✅ [ScreenMultiLang] 다국어 로드 완료:", Object.keys(response.data.data).length, "개"); + setTranslations(response.data.data); + } + } catch (error) { + console.error("❌ [ScreenMultiLang] 다국어 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadTranslations(); + }, [langKeys, userLang, companyCode]); + + // 번역 텍스트 가져오기 헬퍼 + const getTranslatedText = (langKey: string | undefined, fallback: string): string => { + if (!langKey) return fallback; + return translations[langKey] || fallback; + }; + + const value = useMemo( + () => ({ + translations, + loading, + getTranslatedText, + }), + [translations, loading] + ); + + return {children}; +}; + +/** + * 화면 다국어 컨텍스트 사용 훅 + */ +export const useScreenMultiLang = (): ScreenMultiLangContextValue => { + const context = useContext(ScreenMultiLangContext); + if (!context) { + // 컨텍스트가 없으면 기본값 반환 (fallback) + return { + translations: {}, + loading: false, + getTranslatedText: (_, fallback) => fallback, + }; + } + return context; +}; + diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 86155bd6..b8fc64f6 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -27,6 +27,7 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; import { apiClient } from "@/lib/api/client"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; @@ -107,6 +108,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const screenContext = useScreenContextOptional(); // 화면 컨텍스트 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + const { getTranslatedText } = useScreenMultiLang(); // 다국어 컨텍스트 // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) const splitPanelPosition = screenContext?.splitPanelPosition; @@ -1285,7 +1287,10 @@ export const ButtonPrimaryComponent: React.FC = ({ ...userStyle, }; - const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; + // 다국어 적용: componentConfig.langKey가 있으면 번역 텍스트 사용 + const langKey = (component as any).componentConfig?.langKey; + const originalButtonText = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; + const buttonContent = getTranslatedText(langKey, originalButtonText); return ( <> diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index 0f11bbf2..b8ca5545 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -6,6 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { ColumnConfig } from "./types"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; interface SingleTableWithStickyProps { visibleColumns?: ColumnConfig[]; @@ -74,281 +75,298 @@ export const SingleTableWithSticky: React.FC = ({ currentSearchIndex = 0, searchTerm = "", }) => { + const { getTranslatedText } = useScreenMultiLang(); const checkboxConfig = tableConfig?.checkbox || {}; const actualColumns = visibleColumns || columns || []; const sortHandler = onSort || handleSort || (() => {}); return (
- - - - {actualColumns.map((column, colIndex) => { - // 왼쪽 고정 컬럼들의 누적 너비 계산 - const leftFixedWidth = actualColumns - .slice(0, colIndex) - .filter((col) => col.fixed === "left") - .reduce((sum, col) => sum + getColumnWidth(col), 0); + + + {actualColumns.map((column, colIndex) => { + // 왼쪽 고정 컬럼들의 누적 너비 계산 + const leftFixedWidth = actualColumns + .slice(0, colIndex) + .filter((col) => col.fixed === "left") + .reduce((sum, col) => sum + getColumnWidth(col), 0); - // 오른쪽 고정 컬럼들의 누적 너비 계산 - const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right"); - const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); - const rightFixedWidth = - rightFixedIndex >= 0 - ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) - : 0; + // 오른쪽 고정 컬럼들의 누적 너비 계산 + const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right"); + const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); + const rightFixedWidth = + rightFixedIndex >= 0 + ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) + : 0; - return ( - column.sortable && sortHandler(column.columnName)} - > -
- {column.columnName === "__checkbox__" ? ( - checkboxConfig.selectAll && ( - - ) - ) : ( - <> - - {columnLabels[column.columnName] || column.displayName || column.columnName} - - {column.sortable && sortColumn === column.columnName && ( - - {sortDirection === "asc" ? ( - - ) : ( - - )} - - )} - + return ( + - - ); - })} - - - - - {data.length === 0 ? ( - - -
-
- - - -
- 데이터가 없습니다 - - 조건을 변경하여 다시 검색해보세요 - -
-
+ style={{ + width: getColumnWidth(column), + minWidth: "100px", // 최소 너비 보장 + maxWidth: "300px", // 최대 너비 제한 + boxSizing: "border-box", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 + backgroundColor: "hsl(var(--background))", + // sticky 위치 설정 + ...(column.fixed === "left" && { left: leftFixedWidth }), + ...(column.fixed === "right" && { right: rightFixedWidth }), + }} + onClick={() => column.sortable && sortHandler(column.columnName)} + > +
+ {column.columnName === "__checkbox__" ? ( + checkboxConfig.selectAll && ( + + ) + ) : ( + <> + + {/* langKey가 있으면 다국어 번역 사용, 없으면 기존 라벨 */} + {(column as any).langKey + ? getTranslatedText( + (column as any).langKey, + columnLabels[column.columnName] || column.displayName || column.columnName, + ) + : columnLabels[column.columnName] || column.displayName || column.columnName} + + {column.sortable && sortColumn === column.columnName && ( + + {sortDirection === "asc" ? ( + + ) : ( + + )} + + )} + + )} +
+ + ); + })}
- ) : ( - data.map((row, index) => ( - handleRowClick(row)} - > - {visibleColumns.map((column, colIndex) => { - // 왼쪽 고정 컬럼들의 누적 너비 계산 - const leftFixedWidth = visibleColumns - .slice(0, colIndex) - .filter((col) => col.fixed === "left") - .reduce((sum, col) => sum + getColumnWidth(col), 0); + - // 오른쪽 고정 컬럼들의 누적 너비 계산 - const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right"); - const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); - const rightFixedWidth = - rightFixedIndex >= 0 - ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) - : 0; + + {data.length === 0 ? ( + + +
+
+ + + +
+ 데이터가 없습니다 + + 조건을 변경하여 다시 검색해보세요 + +
+
+
+ ) : ( + data.map((row, index) => ( + handleRowClick(row)} + > + {visibleColumns.map((column, colIndex) => { + // 왼쪽 고정 컬럼들의 누적 너비 계산 + const leftFixedWidth = visibleColumns + .slice(0, colIndex) + .filter((col) => col.fixed === "left") + .reduce((sum, col) => sum + getColumnWidth(col), 0); - // 현재 셀이 편집 중인지 확인 - const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex; + // 오른쪽 고정 컬럼들의 누적 너비 계산 + const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right"); + const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); + const rightFixedWidth = + rightFixedIndex >= 0 + ? rightFixedColumns + .slice(rightFixedIndex + 1) + .reduce((sum, col) => sum + getColumnWidth(col), 0) + : 0; - // 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인 - const cellKey = `${index}-${colIndex}`; - const cellValue = String(row[column.columnName] ?? "").toLowerCase(); - const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false; - - // 인덱스 기반 하이라이트 + 실제 값 검증 - const isHighlighted = column.columnName !== "__checkbox__" && - hasSearchTerm && - (searchHighlights?.has(cellKey) ?? false); - - // 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음) - const highlightArray = searchHighlights ? Array.from(searchHighlights) : []; - const isCurrentSearchResult = isHighlighted && - currentSearchIndex >= 0 && - currentSearchIndex < highlightArray.length && - highlightArray[currentSearchIndex] === cellKey; + // 현재 셀이 편집 중인지 확인 + const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex; - // 셀 값에서 검색어 하이라이트 렌더링 - const renderCellContent = () => { - const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; - - if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { - return cellValue; - } + // 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인 + const cellKey = `${index}-${colIndex}`; + const cellValue = String(row[column.columnName] ?? "").toLowerCase(); + const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false; - // 검색어 하이라이트 처리 - const lowerValue = String(cellValue).toLowerCase(); - const lowerTerm = searchTerm.toLowerCase(); - const startIndex = lowerValue.indexOf(lowerTerm); - - if (startIndex === -1) return cellValue; + // 인덱스 기반 하이라이트 + 실제 값 검증 + const isHighlighted = + column.columnName !== "__checkbox__" && + hasSearchTerm && + (searchHighlights?.has(cellKey) ?? false); - const before = String(cellValue).slice(0, startIndex); - const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); - const after = String(cellValue).slice(startIndex + searchTerm.length); + // 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음) + const highlightArray = searchHighlights ? Array.from(searchHighlights) : []; + const isCurrentSearchResult = + isHighlighted && + currentSearchIndex >= 0 && + currentSearchIndex < highlightArray.length && + highlightArray[currentSearchIndex] === cellKey; + + // 셀 값에서 검색어 하이라이트 렌더링 + const renderCellContent = () => { + const cellValue = + formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; + + if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { + return cellValue; + } + + // 검색어 하이라이트 처리 + const lowerValue = String(cellValue).toLowerCase(); + const lowerTerm = searchTerm.toLowerCase(); + const startIndex = lowerValue.indexOf(lowerTerm); + + if (startIndex === -1) return cellValue; + + const before = String(cellValue).slice(0, startIndex); + const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); + const after = String(cellValue).slice(startIndex + searchTerm.length); + + return ( + <> + {before} + + {match} + + {after} + + ); + }; return ( - <> - {before} - - {match} - - {after} - + { + if (onCellDoubleClick && column.columnName !== "__checkbox__") { + e.stopPropagation(); + onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]); + } + }} + > + {column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : isEditing ? ( + // 인라인 편집 입력 필드 + onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={() => { + // blur 시 저장 (Enter와 동일) + if (onEditKeyDown) { + const fakeEvent = { + key: "Enter", + preventDefault: () => {}, + } as React.KeyboardEvent; + onEditKeyDown(fakeEvent); + } + }} + className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + renderCellContent() + )} + ); - }; - - return ( - { - if (onCellDoubleClick && column.columnName !== "__checkbox__") { - e.stopPropagation(); - onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]); - } - }} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxCell(row, index) - ) : isEditing ? ( - // 인라인 편집 입력 필드 - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={() => { - // blur 시 저장 (Enter와 동일) - if (onEditKeyDown) { - const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent; - onEditKeyDown(fakeEvent); - } - }} - className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm" - onClick={(e) => e.stopPropagation()} - /> - ) : ( - renderCellContent() - )} - - ); - })} - - )) - )} -
-
+ })} + + )) + )} + +
); diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6b74240a..5d63005c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -67,6 +67,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // ======================================== // 인터페이스 @@ -243,6 +244,11 @@ export const TableListComponent: React.FC = ({ parentTabsComponentId, companyCode, }) => { + // ======================================== + // 다국어 번역 훅 + // ======================================== + const { getTranslatedText } = useScreenMultiLang(); + // ======================================== // 설정 및 스타일 // ======================================== @@ -5821,7 +5827,10 @@ export const TableListComponent: React.FC = ({ rowSpan={2} className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm" > - {columnLabels[column.columnName] || column.columnName} + {/* langKey가 있으면 다국어 번역 사용 */} + {(column as any).langKey + ? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.columnName) + : columnLabels[column.columnName] || column.columnName} ); } @@ -5917,7 +5926,12 @@ export const TableListComponent: React.FC = ({ )} - {columnLabels[column.columnName] || column.displayName} + + {/* langKey가 있으면 다국어 번역 사용 */} + {(column as any).langKey + ? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.displayName || column.columnName) + : columnLabels[column.columnName] || column.displayName} + {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )}