feat/multilang #357
|
|
@ -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 (
|
||||
<div>
|
||||
<h2>{displayTitle}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{translatedColumns?.map((col, idx) => (
|
||||
<th key={idx}>{col.displayLabel}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<button>{buttonText}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 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<string> => {
|
||||
const keys = new Set<string>();
|
||||
|
||||
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<string> => {
|
||||
const tableNames = new Set<string>();
|
||||
|
||||
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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||
|
|
@ -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<MultilangSettingsModalProps> = ({
|
|||
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
|
||||
// 테이블별 컬럼 라벨 매핑 (columnName -> displayName)
|
||||
const [columnLabelMap, setColumnLabelMap] = useState<Record<string, Record<string, string>>>({});
|
||||
// 번역 필터 상태
|
||||
const [translationFilter, setTranslationFilter] = useState<TranslationFilter>("all");
|
||||
// 항목별 번역 상태 캐시 (itemId -> status)
|
||||
const [translationStatusCache, setTranslationStatusCache] = useState<Record<string, TranslationStatus>>({});
|
||||
// 선택된 라벨 항목
|
||||
const [selectedLabelItem, setSelectedLabelItem] = useState<ExtractedLabel | null>(null);
|
||||
// 선택된 라벨의 번역 텍스트 로드
|
||||
const [isLoadingTranslations, setIsLoadingTranslations] = useState(false);
|
||||
// 모든 항목의 편집된 번역을 저장하는 맵 (itemId -> langCode -> text)
|
||||
const [allEditedTranslations, setAllEditedTranslations] = useState<Record<string, Record<string, string>>>({});
|
||||
// 저장 중 상태
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
// 활성화된 언어 목록
|
||||
const [activeLanguages, setActiveLanguages] = useState<Array<{ langCode: string; langName: string; langNative: string }>>([]);
|
||||
|
||||
// 컴포넌트에서 사용되는 테이블명 추출
|
||||
const getTableNamesFromComponents = useCallback((comps: ComponentData[]): Set<string> => {
|
||||
|
|
@ -747,6 +778,137 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
}
|
||||
}, [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<string, Record<string, string>> = {};
|
||||
const newStatusCache: Record<string, TranslationStatus> = {};
|
||||
|
||||
// 각 키의 번역 상태 조회
|
||||
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<string, string> = {};
|
||||
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<string, string>);
|
||||
|
||||
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 <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "partial":
|
||||
return <CircleDot className="h-4 w-4 text-yellow-500" />;
|
||||
case "none":
|
||||
default:
|
||||
return <Circle className="h-4 w-4 text-gray-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 번역 상태별 통계 계산
|
||||
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<MultilangSettingsModalProps> = ({
|
|||
|
||||
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<MultilangSettingsModalProps> = ({
|
|||
)
|
||||
: 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<MultilangSettingsModalProps> = ({
|
|||
});
|
||||
|
||||
return groups;
|
||||
}, [extractedLabels, searchText, expandedGroups]);
|
||||
}, [extractedLabels, searchText, expandedGroups, translationFilter, getTranslationStatus]);
|
||||
|
||||
// 그룹 펼침/접기 토글
|
||||
const toggleGroup = (groupId: string) => {
|
||||
|
|
@ -815,23 +990,8 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
const connectedCount = selectedItems.size;
|
||||
const totalCount = extractedLabels.length;
|
||||
|
||||
// 선택된 라벨 항목
|
||||
const [selectedLabelItem, setSelectedLabelItem] = useState<ExtractedLabel | null>(null);
|
||||
|
||||
// 선택된 라벨의 번역 텍스트 로드
|
||||
const [isLoadingTranslations, setIsLoadingTranslations] = useState(false);
|
||||
|
||||
// 모든 항목의 편집된 번역을 저장하는 맵 (itemId -> langCode -> text)
|
||||
const [allEditedTranslations, setAllEditedTranslations] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
// 현재 선택된 항목의 편집된 번역
|
||||
const currentTranslations = selectedLabelItem ? (allEditedTranslations[selectedLabelItem.id] || {}) : {};
|
||||
|
||||
// 저장 중 상태
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 활성화된 언어 목록
|
||||
const [activeLanguages, setActiveLanguages] = useState<Array<{ langCode: string; langName: string; langNative: string }>>([]);
|
||||
|
||||
// 언어 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -908,6 +1068,21 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
...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<MultilangSettingsModalProps> = ({
|
|||
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<MultilangSettingsModalProps> = ({
|
|||
if (!isOpen) {
|
||||
setSelectedLabelItem(null);
|
||||
setAllEditedTranslations({});
|
||||
setTranslationFilter("all");
|
||||
setTranslationStatusCache({});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
|
@ -983,15 +1182,30 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
<div className="flex gap-4" style={{ height: "500px" }}>
|
||||
{/* 왼쪽: 라벨 목록 */}
|
||||
<div className="flex w-1/2 flex-col overflow-hidden">
|
||||
{/* 검색 */}
|
||||
<div className="relative mb-2 shrink-0">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="라벨 또는 키로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="mb-2 flex shrink-0 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="라벨 또는 키로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={translationFilter}
|
||||
onValueChange={(value) => setTranslationFilter(value as TranslationFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="필터" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="complete">번역완료</SelectItem>
|
||||
<SelectItem value="incomplete">번역필요</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 목록 */}
|
||||
|
|
@ -1027,6 +1241,7 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
{group.items.map((item) => {
|
||||
const isConnected = selectedItems.has(item.id);
|
||||
const isSelected = selectedLabelItem?.id === item.id;
|
||||
const translationStatus = getTranslationStatus(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1036,18 +1251,16 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
"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"
|
||||
)}
|
||||
>
|
||||
{/* 상태 아이콘 */}
|
||||
{/* 번역 상태 아이콘 */}
|
||||
<div className="shrink-0">
|
||||
{isConnected ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<TranslationStatusIcon status={translationStatus} />
|
||||
</div>
|
||||
|
||||
{/* 타입 배지 */}
|
||||
|
|
@ -1150,6 +1363,31 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 진행률 표시 */}
|
||||
<div className="mb-2 rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="font-medium">번역 현황</span>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
완료 {translationStats.complete}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CircleDot className="h-3 w-3 text-yellow-500" />
|
||||
부분 {translationStats.partial}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Circle className="h-3 w-3 text-gray-300" />
|
||||
미완료 {translationStats.none}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{progressPercentage}%</span>
|
||||
</div>
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
닫기
|
||||
|
|
|
|||
Loading…
Reference in New Issue