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}%
+
+
+
+