diff --git a/.cursor/rules/multilang-component-guide.mdc b/.cursor/rules/multilang-component-guide.mdc new file mode 100644 index 00000000..60bdc0ec --- /dev/null +++ b/.cursor/rules/multilang-component-guide.mdc @@ -0,0 +1,559 @@ +# 다국어 지원 컴포넌트 개발 가이드 + +새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다. +이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다. + +--- + +## 1. 타입 정의 시 다국어 필드 추가 + +### 기본 원칙 + +텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다. + +### 단일 텍스트 속성 + +```typescript +interface MyComponentConfig { + // 기본 텍스트 + title?: string; + // 다국어 키 (필수 추가) + titleLangKeyId?: number; + titleLangKey?: string; + + // 라벨 + label?: string; + labelLangKeyId?: number; + labelLangKey?: string; + + // 플레이스홀더 + placeholder?: string; + placeholderLangKeyId?: number; + placeholderLangKey?: string; +} +``` + +### 배열/목록 속성 (컬럼, 탭 등) + +```typescript +interface ColumnConfig { + name: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 기타 속성 + width?: number; + align?: "left" | "center" | "right"; +} + +interface TabConfig { + id: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 탭 제목도 별도로 + title?: string; + titleLangKeyId?: number; + titleLangKey?: string; +} + +interface MyComponentConfig { + columns?: ColumnConfig[]; + tabs?: TabConfig[]; +} +``` + +### 버튼 컴포넌트 + +```typescript +interface ButtonComponentConfig { + text?: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; +} +``` + +### 실제 예시: 분할 패널 + +```typescript +interface SplitPanelLayoutConfig { + leftPanel?: { + title?: string; + langKeyId?: number; // 좌측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; // 각 컬럼 다국어 + langKey?: string; + }>; + }; + rightPanel?: { + title?: string; + langKeyId?: number; // 우측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + additionalTabs?: Array<{ + label: string; + langKeyId?: number; // 탭 라벨 다국어 + langKey?: string; + title?: string; + titleLangKeyId?: number; // 탭 제목 다국어 + titleLangKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + }>; + }; +} +``` + +--- + +## 2. 라벨 추출 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractMultilangLabels` 함수에 추가 + +새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다. + +```typescript +// 새 컴포넌트 타입 체크 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 추출 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 2. 컬럼 추출 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col, index) => { + const colLabel = col.label || col.name; + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: colLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } + + // 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우) + if (config?.text) { + addLabel({ + id: `${comp.id}_button`, + componentId: `${comp.id}_button`, + label: config.text, + type: "button", + parentType: "my-new-component", + parentLabel: config.text, + langKeyId: config.langKeyId, + langKey: config.langKey, + }); + } +} +``` + +### 추출해야 할 라벨 타입 + +| 타입 | 설명 | 예시 | +| ------------- | ------------------ | ------------------------ | +| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 | +| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 | +| `button` | 버튼 텍스트 | 저장, 취소, 삭제 | +| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 | +| `tab` | 탭 라벨 | 기본정보, 상세정보 | +| `filter` | 검색 필터 라벨 | 검색어, 기간 | +| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" | +| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 | + +--- + +## 3. 매핑 적용 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `applyMultilangMappings` 함수에 추가 + +다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다. + +```typescript +// 새 컴포넌트 매핑 적용 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 매핑 + const titleMapping = mappingMap.get(`${comp.id}_title`); + if (titleMapping) { + updated.componentConfig = { + ...updated.componentConfig, + titleLangKeyId: titleMapping.keyId, + titleLangKey: titleMapping.langKey, + }; + } + + // 2. 컬럼 매핑 + if (config?.columns && Array.isArray(config.columns)) { + const updatedColumns = config.columns.map((col, index) => { + const colMapping = mappingMap.get(`${comp.id}_col_${index}`); + if (colMapping) { + return { + ...col, + langKeyId: colMapping.keyId, + langKey: colMapping.langKey, + }; + } + return col; + }); + updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, + }; + } + + // 3. 버튼 매핑 (버튼 컴포넌트인 경우) + const buttonMapping = mappingMap.get(`${comp.id}_button`); + if (buttonMapping) { + updated.componentConfig = { + ...updated.componentConfig, + langKeyId: buttonMapping.keyId, + langKey: buttonMapping.langKey, + }; + } +} +``` + +### 주의사항 + +- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다. +- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다. + +```typescript +// 잘못된 방법 - 이전 업데이트 덮어쓰기 +updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌ +updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐! + +// 올바른 방법 - 이전 업데이트 유지 +updated.componentConfig = { + ...updated.componentConfig, + langKeyId: mapping.keyId, +}; // ✅ +updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, +}; // ✅ +``` + +--- + +## 4. 번역 표시 로직 구현 + +### 파일 위치 + +새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`) + +### Context 사용 + +```typescript +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; + +const MyComponent = ({ component }: Props) => { + const { getTranslatedText } = useScreenMultiLang(); + const config = component.componentConfig; + + // 제목 번역 + const displayTitle = config?.titleLangKey + ? getTranslatedText(config.titleLangKey, config.title || "") + : config?.title || ""; + + // 컬럼 헤더 번역 + const translatedColumns = config?.columns?.map((col) => ({ + ...col, + displayLabel: col.langKey + ? getTranslatedText(col.langKey, col.label) + : col.label, + })); + + // 버튼 텍스트 번역 + const buttonText = config?.langKey + ? getTranslatedText(config.langKey, config.text || "") + : config?.text || ""; + + return ( +
+

{displayTitle}

+ + + + {translatedColumns?.map((col, idx) => ( + + ))} + + +
{col.displayLabel}
+ +
+ ); +}; +``` + +### getTranslatedText 함수 + +```typescript +// 첫 번째 인자: langKey (다국어 키) +// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값) +const text = getTranslatedText( + "screen.company_1.Sales.OrderList.품목명", + "품목명" +); +``` + +### 주의사항 + +- `langKey`가 없으면 원본 텍스트를 표시합니다. +- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다. +- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다. + +--- + +## 5. ScreenMultiLangContext에 키 수집 로직 추가 + +### 파일 위치 + +`frontend/contexts/ScreenMultiLangContext.tsx` + +### `collectLangKeys` 함수에 추가 + +번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다. + +```typescript +const collectLangKeys = (comps: ComponentData[]): Set => { + const keys = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 langKey 수집 + if (comp.componentType === "my-new-component") { + // 제목 + if (config?.titleLangKey) { + keys.add(config.titleLangKey); + } + + // 컬럼 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col: any) => { + if (col.langKey) { + keys.add(col.langKey); + } + }); + } + + // 버튼 + if (config?.langKey) { + keys.add(config.langKey); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return keys; +}; +``` + +--- + +## 6. MultilangSettingsModal에 표시 로직 추가 + +### 파일 위치 + +`frontend/components/screen/modals/MultilangSettingsModal.tsx` + +### `extractLabelsFromComponents` 함수에 추가 + +다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다. + +```typescript +// 새 컴포넌트 라벨 추출 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 제목 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 컬럼 + if (config?.columns) { + config.columns.forEach((col, index) => { + // columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우) + const tableName = config.tableName; + const displayLabel = + tableName && columnLabelMap[tableName]?.[col.name] + ? columnLabelMap[tableName][col.name] + : col.label || col.name; + + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: displayLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } +} +``` + +--- + +## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우) + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractTableNames` 함수에 추가 + +컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다. + +```typescript +const extractTableNames = (comps: ComponentData[]): Set => { + const tableNames = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 테이블명 추출 + if (comp.componentType === "my-new-component") { + if (config?.tableName) { + tableNames.add(config.tableName); + } + if (config?.selectedTable) { + tableNames.add(config.selectedTable); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return tableNames; +}; +``` + +--- + +## 8. 체크리스트 + +새 컴포넌트 개발 시 다음 항목을 확인하세요: + +### 타입 정의 + +- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가 +- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가 + +### 라벨 추출 (multilangLabelExtractor.ts) + +- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가 +- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우) + +### 매핑 적용 (multilangLabelExtractor.ts) + +- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가 + +### 번역 표시 (컴포넌트 파일) + +- [ ] `useScreenMultiLang` 훅 사용 +- [ ] `getTranslatedText`로 텍스트 번역 적용 + +### 키 수집 (ScreenMultiLangContext.tsx) + +- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가 + +### 설정 모달 (MultilangSettingsModal.tsx) + +- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가 + +--- + +## 9. 관련 파일 목록 + +| 파일 | 역할 | +| -------------------------------------------------------------- | ----------------------- | +| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 | +| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 | +| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI | +| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 | +| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 | + +--- + +## 10. 주의사항 + +1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용 + + - 제목: `${comp.id}_title` + - 컬럼: `${comp.id}_col_${index}` + - 버튼: `${comp.id}_button` + +2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정 + + - `${comp.id}_left_title`, `${comp.id}_right_col_${index}` + +3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트 + +4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리 + +5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트 diff --git a/frontend/components/screen/modals/MultilangSettingsModal.tsx b/frontend/components/screen/modals/MultilangSettingsModal.tsx index 1c2ba551..f9f365dc 100644 --- a/frontend/components/screen/modals/MultilangSettingsModal.tsx +++ b/frontend/components/screen/modals/MultilangSettingsModal.tsx @@ -40,7 +40,18 @@ import { 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"; @@ -114,6 +125,12 @@ interface ExtractedLabel { langKey?: string; } +// 번역 상태 타입 +type TranslationStatus = "complete" | "partial" | "none"; + +// 번역 필터 타입 +type TranslationFilter = "all" | "complete" | "incomplete"; + // 그룹화된 라벨 타입 interface LabelGroup { id: string; @@ -315,6 +332,20 @@ export const MultilangSettingsModal: React.FC = ({ 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 => { @@ -747,6 +778,137 @@ export const MultilangSettingsModal: React.FC = ({ } }, [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[] = []; @@ -762,7 +924,7 @@ export const MultilangSettingsModal: React.FC = ({ groupMap.forEach((items, key) => { // 검색 필터링 - const filteredItems = searchText + let filteredItems = searchText ? items.filter( (item) => item.label.toLowerCase().includes(searchText.toLowerCase()) || @@ -770,6 +932,19 @@ export const MultilangSettingsModal: React.FC = ({ ) : 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, @@ -783,7 +958,7 @@ export const MultilangSettingsModal: React.FC = ({ }); return groups; - }, [extractedLabels, searchText, expandedGroups]); + }, [extractedLabels, searchText, expandedGroups, translationFilter, getTranslationStatus]); // 그룹 펼침/접기 토글 const toggleGroup = (groupId: string) => { @@ -815,23 +990,8 @@ 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(() => { @@ -908,6 +1068,21 @@ export const MultilangSettingsModal: React.FC = ({ ...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); @@ -923,12 +1098,34 @@ export const MultilangSettingsModal: React.FC = ({ const handleTranslationChange = (langCode: string, text: string) => { if (!selectedLabelItem) return; + const updatedTranslations = { + ...(allEditedTranslations[selectedLabelItem.id] || {}), + [langCode]: text, + }; + setAllEditedTranslations((prev) => ({ ...prev, - [selectedLabelItem.id]: { - ...(prev[selectedLabelItem.id] || {}), - [langCode]: text, - }, + [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, })); }; @@ -963,6 +1160,8 @@ export const MultilangSettingsModal: React.FC = ({ if (!isOpen) { setSelectedLabelItem(null); setAllEditedTranslations({}); + setTranslationFilter("all"); + setTranslationStatusCache({}); } }, [isOpen]); @@ -983,15 +1182,30 @@ export const MultilangSettingsModal: React.FC = ({
{/* 왼쪽: 라벨 목록 */}
- {/* 검색 */} -
- - setSearchText(e.target.value)} - className="pl-10" - /> + {/* 검색 및 필터 */} +
+
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+
{/* 라벨 목록 */} @@ -1027,6 +1241,7 @@ export const MultilangSettingsModal: React.FC = ({ {group.items.map((item) => { const isConnected = selectedItems.has(item.id); const isSelected = selectedLabelItem?.id === item.id; + const translationStatus = getTranslationStatus(item.id); return (
= ({ "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 + : translationStatus === "complete" ? "border-green-200 bg-green-50 hover:bg-green-100" - : "border-gray-200 bg-white hover:bg-gray-50" + : translationStatus === "partial" + ? "border-yellow-200 bg-yellow-50 hover:bg-yellow-100" + : "border-gray-200 bg-white hover:bg-gray-50" )} > - {/* 상태 아이콘 */} + {/* 번역 상태 아이콘 */}
- {isConnected ? ( - - ) : ( - - )} +
{/* 타입 배지 */} @@ -1150,6 +1363,31 @@ export const MultilangSettingsModal: React.FC = ({
+ {/* 하단 진행률 표시 */} +
+
+
+ 번역 현황 +
+ + + 완료 {translationStats.complete} + + + + 부분 {translationStats.partial} + + + + 미완료 {translationStats.none} + +
+
+ {progressPercentage}% +
+ +
+