ERP-node/frontend/components/screen/modals/MultilangSettingsModal.tsx

1436 lines
49 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
ChevronRight,
ChevronDown,
Search,
Check,
X,
Languages,
Table2,
LayoutPanelLeft,
Type,
MousePointer,
Filter,
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";
// 다국어 키 타입
interface LangKey {
keyId: number;
langKey: string;
description?: string;
categoryName?: string;
}
// 번역 텍스트 타입
interface LangText {
langCode: string;
langName: string;
langNative: string;
text: string;
}
// 입력 가능한 폼 컴포넌트 타입 목록
const INPUT_COMPONENT_TYPES = new Set([
"text-input",
"number-input",
"date-input",
"datetime-input",
"select-input",
"textarea-input",
"checkbox-input",
"radio-input",
"file-input",
"email-input",
"phone-input",
"entity-input",
"code-input",
// 위젯 타입도 포함
"widget",
"input",
"field",
]);
// 입력 가능한 웹타입 목록
const INPUT_WEB_TYPES = new Set([
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"boolean",
"checkbox",
"radio",
"code",
"entity",
"file",
"email",
"tel",
]);
// 추출된 라벨 항목 타입
interface ExtractedLabel {
id: string;
componentId: string;
label: string;
type: "label" | "title" | "button" | "placeholder" | "column" | "filter" | "field" | "tab" | "action";
parentType?: string;
parentLabel?: string;
langKeyId?: number;
langKey?: string;
}
// 번역 상태 타입
type TranslationStatus = "complete" | "partial" | "none";
// 번역 필터 타입
type TranslationFilter = "all" | "complete" | "incomplete";
// 그룹화된 라벨 타입
interface LabelGroup {
id: string;
label: string;
type: string;
icon: React.ReactNode;
items: ExtractedLabel[];
isExpanded: boolean;
}
interface MultilangSettingsModalProps {
isOpen: boolean;
onClose: () => void;
components: ComponentData[];
onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void;
}
// 타입별 아이콘 매핑
const getTypeIcon = (type: string) => {
switch (type) {
case "button":
return <MousePointer className="h-4 w-4" />;
case "table-list":
return <Table2 className="h-4 w-4" />;
case "split-panel-layout":
return <LayoutPanelLeft className="h-4 w-4" />;
case "filter":
return <Filter className="h-4 w-4" />;
case "field":
case "input":
return <FormInput className="h-4 w-4" />;
default:
return <Type className="h-4 w-4" />;
}
};
// 타입별 한글 라벨
const getTypeLabel = (type: string) => {
switch (type) {
case "button":
return "버튼";
case "label":
return "라벨";
case "title":
return "제목";
case "column":
return "컬럼";
case "filter":
return "필터";
case "field":
return "필드";
case "placeholder":
return "플레이스홀더";
case "tab":
return "탭";
case "action":
return "액션";
default:
return type;
}
};
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등)
const NON_INPUT_COMPONENT_TYPES = new Set([
"table-list",
"split-panel-layout",
"tab-panel",
"container",
"group",
"layout",
"panel",
"card",
"accordion",
"modal",
"drawer",
"form-layout",
]);
// 컴포넌트가 입력 폼인지 확인
const isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type;
const webType = comp.webType || comp.componentConfig?.webType;
// 명시적으로 제외되는 컴포넌트 타입
if (NON_INPUT_COMPONENT_TYPES.has(compType)) {
return false;
}
// 컴포넌트 타입으로 확인
if (INPUT_COMPONENT_TYPES.has(compType)) {
return true;
}
// 웹타입으로 확인
if (webType && INPUT_WEB_TYPES.has(webType)) {
return true;
}
return false;
};
// 키 선택 콤보박스 컴포넌트
interface KeySelectComboboxProps {
value?: { keyId: number; langKey: string };
onChange: (value: { keyId: number; langKey: string } | null) => void;
langKeys: LangKey[];
isLoading: boolean;
}
const KeySelectCombobox: React.FC<KeySelectComboboxProps> = ({
value,
onChange,
langKeys,
isLoading,
}) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-7 w-[180px] justify-between text-xs"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : value ? (
<span className="truncate">{value.langKey}</span>
) : (
<span className="text-muted-foreground"> ...</span>
)}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
<CommandInput placeholder="키 검색..." className="h-9" />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{/* 연결 해제 옵션 */}
{value && (
<CommandItem
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-red-600"
>
<X className="mr-2 h-4 w-4" />
</CommandItem>
)}
{langKeys.map((key) => (
<CommandItem
key={key.keyId}
value={key.langKey}
onSelect={() => {
onChange({ keyId: key.keyId, langKey: key.langKey });
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value?.keyId === key.keyId ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{key.langKey}</span>
{key.description && (
<span className="text-xs text-muted-foreground">{key.description}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
isOpen,
onClose,
components,
onSave,
}) => {
const [searchText, setSearchText] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedItems, setSelectedItems] = useState<Map<string, { langKeyId?: number; langKey?: string }>>(new Map());
const [extractedLabels, setExtractedLabels] = useState<ExtractedLabel[]>([]);
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
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> => {
const tableNames = new Set<string>();
const extractTableName = (comp: any) => {
const config = comp.componentConfig;
// 1. 최상위 tableName
if (comp.tableName) tableNames.add(comp.tableName);
// 2. componentConfig 직접 tableName
if (config?.tableName) tableNames.add(config.tableName);
// 3. 테이블 리스트 컴포넌트 - selectedTable (주요!)
if (config?.selectedTable) tableNames.add(config.selectedTable);
// 4. 테이블 리스트 컴포넌트 - table 속성
if (config?.table) tableNames.add(config.table);
// 5. 분할 패널의 leftPanel/rightPanel
if (config?.leftPanel?.tableName) tableNames.add(config.leftPanel.tableName);
if (config?.rightPanel?.tableName) tableNames.add(config.rightPanel.tableName);
if (config?.leftPanel?.table) tableNames.add(config.leftPanel.table);
if (config?.rightPanel?.table) tableNames.add(config.rightPanel.table);
if (config?.leftPanel?.selectedTable) tableNames.add(config.leftPanel.selectedTable);
if (config?.rightPanel?.selectedTable) tableNames.add(config.rightPanel.selectedTable);
// 6. 검색 필터의 tableName
if (config?.filter?.tableName) tableNames.add(config.filter.tableName);
// 7. properties 안의 tableName
if (comp.properties?.tableName) tableNames.add(comp.properties.tableName);
if (comp.properties?.selectedTable) tableNames.add(comp.properties.selectedTable);
// 자식 컴포넌트 탐색
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(extractTableName);
}
};
comps.forEach(extractTableName);
return tableNames;
}, []);
// 이미 로드한 테이블 추적 (무한 루프 방지)
const loadedTablesRef = useRef<Set<string>>(new Set());
// 다국어 키 목록 로드 및 테이블별 컬럼 라벨 조회
useEffect(() => {
if (!isOpen) return;
// 다국어 키 목록 로드
const fetchLangKeys = async () => {
setIsLoadingKeys(true);
try {
const response = await apiClient.get("/multilang/keys");
if (response.data?.success && response.data?.data) {
setLangKeys(response.data.data.map((k: any) => ({
keyId: k.keyId,
langKey: k.langKey,
description: k.description,
categoryName: k.categoryName,
})));
}
} catch (error) {
console.error("다국어 키 목록 로드 실패:", error);
} finally {
setIsLoadingKeys(false);
}
};
fetchLangKeys();
// 테이블별 컬럼 라벨 조회
const tableNames = getTableNamesFromComponents(components);
// 이미 로드한 테이블은 제외
const tablesToLoad = Array.from(tableNames).filter((t) => !loadedTablesRef.current.has(t));
if (tablesToLoad.length > 0) {
const fetchColumnLabelsForTables = async () => {
const newLabelMap: Record<string, Record<string, string>> = {};
for (const tableName of tablesToLoad) {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data?.success && response.data?.data) {
const columns = response.data.data.columns || response.data.data;
if (Array.isArray(columns)) {
newLabelMap[tableName] = {};
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name || col.name;
const colLabel = col.displayName || col.columnLabel || col.column_label || colName;
if (colName) {
newLabelMap[tableName][colName] = colLabel;
}
});
loadedTablesRef.current.add(tableName);
}
}
} catch (error) {
console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error);
}
}
if (Object.keys(newLabelMap).length > 0) {
setColumnLabelMap((prev) => ({ ...prev, ...newLabelMap }));
}
};
fetchColumnLabelsForTables();
}
}, [isOpen, components, getTableNamesFromComponents]);
// 컴포넌트에서 라벨 추출
const extractLabelsFromComponents = useCallback((comps: ComponentData[]): ExtractedLabel[] => {
const labels: ExtractedLabel[] = [];
const addedLabels = new Set<string>();
const addLabel = (
componentId: string,
label: string,
type: ExtractedLabel["type"],
parentType?: string,
parentLabel?: string,
langKeyId?: number,
langKey?: string
) => {
const key = `${componentId}_${type}_${label}`;
if (label && label.trim() && !addedLabels.has(key)) {
addedLabels.add(key);
labels.push({
id: key,
componentId,
label: label.trim(),
type,
parentType,
parentLabel,
langKeyId,
langKey,
});
}
};
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
const anyComp = comp as any;
const config = anyComp.componentConfig;
const compType = anyComp.componentType || anyComp.type;
const compLabel = anyComp.label || anyComp.title || compType;
// 1. 기본 라벨 - 입력 폼 컴포넌트인 경우에만 추출
if (isInputComponent(anyComp)) {
if (anyComp.label && typeof anyComp.label === "string") {
addLabel(comp.id, anyComp.label, "label", parentType, parentLabel, anyComp.langKeyId, anyComp.langKey);
}
}
// 2. 제목
if (anyComp.title && typeof anyComp.title === "string") {
addLabel(comp.id, anyComp.title, "title", parentType, parentLabel);
}
// 3. 버튼 텍스트 (componentId에 _button 접미사 추가하여 라벨과 구분)
if (config?.text && typeof config.text === "string") {
addLabel(
`${comp.id}_button`,
config.text,
"button",
parentType,
parentLabel,
config.langKeyId,
config.langKey
);
}
// 4. placeholder
if (anyComp.placeholder && typeof anyComp.placeholder === "string") {
addLabel(comp.id, anyComp.placeholder, "placeholder", parentType, parentLabel);
}
// 5. 테이블 컬럼 - columnLabelMap에서 한글 라벨 조회
const tableName = config?.selectedTable || config?.tableName || config?.table || anyComp.tableName;
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
// columnLabelMap에서 한글 라벨 조회, 없으면 displayName 사용
let colLabel = columnLabelMap[tableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_col_${index}`,
colLabel,
"column",
compType,
compLabel,
col.langKeyId,
col.langKey
);
}
});
}
// 6. 분할 패널 제목 및 컬럼 - columnLabelMap에서 한글 라벨 조회
// 6-1. 좌측 패널 제목
if (config?.leftPanel?.title && typeof config.leftPanel.title === "string") {
addLabel(
`${comp.id}_left_title`,
config.leftPanel.title,
"title",
compType,
`${compLabel} (좌측)`,
config.leftPanel.langKeyId,
config.leftPanel.langKey
);
}
// 6-2. 우측 패널 제목
if (config?.rightPanel?.title && typeof config.rightPanel.title === "string") {
addLabel(
`${comp.id}_right_title`,
config.rightPanel.title,
"title",
compType,
`${compLabel} (우측)`,
config.rightPanel.langKeyId,
config.rightPanel.langKey
);
}
// 6-3. 좌측 패널 컬럼
const leftTableName = config?.leftPanel?.selectedTable || config?.leftPanel?.tableName || tableName;
if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) {
config.leftPanel.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[leftTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_left_col_${index}`,
colLabel,
"column",
compType,
`${compLabel} (좌측)`,
col.langKeyId,
col.langKey
);
}
});
}
const rightTableName = config?.rightPanel?.selectedTable || config?.rightPanel?.tableName || tableName;
if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) {
config.rightPanel.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[rightTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_right_col_${index}`,
colLabel,
"column",
compType,
`${compLabel} (우측)`,
col.langKeyId,
col.langKey
);
}
});
}
// 6-5. 추가 탭 (additionalTabs) 제목 및 컬럼 - rightPanel.additionalTabs 확인
const additionalTabs = config?.rightPanel?.additionalTabs || config?.additionalTabs;
if (additionalTabs && Array.isArray(additionalTabs)) {
additionalTabs.forEach((tab: any, tabIndex: number) => {
// 탭 라벨
if (tab.label && typeof tab.label === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_label`,
tab.label,
"tab",
compType,
compLabel,
tab.langKeyId,
tab.langKey
);
}
// 탭 제목
if (tab.title && typeof tab.title === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_title`,
tab.title,
"title",
compType,
`${compLabel} (탭: ${tab.label || tabIndex})`,
tab.titleLangKeyId,
tab.titleLangKey
);
}
// 탭 컬럼
const tabTableName = tab.tableName || tab.selectedTable || rightTableName;
if (tab.columns && Array.isArray(tab.columns)) {
tab.columns.forEach((col: any, colIndex: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[tabTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_col_${colIndex}`,
colLabel,
"column",
compType,
`${compLabel} (탭: ${tab.label || tabIndex})`,
col.langKeyId,
col.langKey
);
}
});
}
});
}
// 7. 검색 필터
if (config?.filter?.filters && Array.isArray(config.filter.filters)) {
config.filter.filters.forEach((filter: any, index: number) => {
if (filter.label && typeof filter.label === "string") {
addLabel(
`${comp.id}_filter_${index}`,
filter.label,
"filter",
compType,
compLabel,
filter.langKeyId,
filter.langKey
);
}
});
}
// 8. 폼 필드
if (config?.fields && Array.isArray(config.fields)) {
config.fields.forEach((field: any, index: number) => {
if (field.label && typeof field.label === "string") {
addLabel(
`${comp.id}_field_${index}`,
field.label,
"field",
compType,
compLabel,
field.langKeyId,
field.langKey
);
}
});
}
// 9. 탭
if (config?.tabs && Array.isArray(config.tabs)) {
config.tabs.forEach((tab: any, index: number) => {
if (tab.label && typeof tab.label === "string") {
addLabel(
`${comp.id}_tab_${index}`,
tab.label,
"tab",
compType,
compLabel,
tab.langKeyId,
tab.langKey
);
}
});
}
// 10. 액션 버튼
if (config?.actions?.actions && Array.isArray(config.actions.actions)) {
config.actions.actions.forEach((action: any, index: number) => {
if (action.label && typeof action.label === "string") {
addLabel(
`${comp.id}_action_${index}`,
action.label,
"action",
compType,
compLabel,
action.langKeyId,
action.langKey
);
}
});
}
// 자식 컴포넌트 재귀 탐색
if (anyComp.children && Array.isArray(anyComp.children)) {
anyComp.children.forEach((child: ComponentData) => {
extractFromComponent(child, compType, compLabel);
});
}
};
comps.forEach((comp) => extractFromComponent(comp));
return labels;
}, [columnLabelMap]);
// 컴포넌트 변경 시 또는 columnLabelMap 로드 후 라벨 재추출
useEffect(() => {
if (isOpen && Object.keys(columnLabelMap).length > 0) {
const labels = extractLabelsFromComponents(components);
setExtractedLabels(labels);
// 기존 langKeyId가 있는 항목들 선택 상태로 초기화
const initialSelected = new Map<string, { langKeyId?: number; langKey?: string }>();
labels.forEach((label) => {
if (label.langKeyId) {
initialSelected.set(label.id, { langKeyId: label.langKeyId, langKey: label.langKey });
}
});
setSelectedItems(initialSelected);
// 기본적으로 모든 그룹 펼치기
const groupIds = new Set<string>();
labels.forEach((l) => groupIds.add(l.parentType || l.type));
setExpandedGroups(groupIds);
} else if (isOpen) {
// columnLabelMap 로드 전이라도 일단 컴포넌트 라벨은 표시
const labels = extractLabelsFromComponents(components);
setExtractedLabels(labels);
const initialSelected = new Map<string, { langKeyId?: number; langKey?: string }>();
labels.forEach((label) => {
if (label.langKeyId) {
initialSelected.set(label.id, { langKeyId: label.langKeyId, langKey: label.langKey });
}
});
setSelectedItems(initialSelected);
const groupIds = new Set<string>();
labels.forEach((l) => groupIds.add(l.parentType || l.type));
setExpandedGroups(groupIds);
}
}, [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[] = [];
const groupMap = new Map<string, ExtractedLabel[]>();
extractedLabels.forEach((label) => {
const groupKey = label.parentType || label.type;
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, []);
}
groupMap.get(groupKey)!.push(label);
});
groupMap.forEach((items, key) => {
// 검색 필터링
let filteredItems = searchText
? items.filter(
(item) =>
item.label.toLowerCase().includes(searchText.toLowerCase()) ||
item.langKey?.toLowerCase().includes(searchText.toLowerCase())
)
: 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,
label: items[0].parentLabel || getTypeLabel(key),
type: key,
icon: getTypeIcon(key),
items: filteredItems,
isExpanded: expandedGroups.has(key),
});
}
});
return groups;
}, [extractedLabels, searchText, expandedGroups, translationFilter, getTranslationStatus]);
// 그룹 펼침/접기 토글
const toggleGroup = (groupId: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
// 키 선택 핸들러
const handleKeySelect = (itemId: string, key: { keyId: number; langKey: string } | null) => {
setSelectedItems((prev) => {
const next = new Map(prev);
if (key) {
next.set(itemId, { langKeyId: key.keyId, langKey: key.langKey });
} else {
next.delete(itemId);
}
return next;
});
};
// 연결된 키 개수
const connectedCount = selectedItems.size;
const totalCount = extractedLabels.length;
// 현재 선택된 항목의 편집된 번역
const currentTranslations = selectedLabelItem ? (allEditedTranslations[selectedLabelItem.id] || {}) : {};
// 언어 목록 로드
useEffect(() => {
const loadLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
if (response.data?.success && Array.isArray(response.data.data)) {
const activeLangs = response.data.data
.filter((lang: any) => (lang.isActive || lang.is_active) === "Y")
.map((lang: any) => ({
langCode: lang.langCode || lang.lang_code,
langName: lang.langName || lang.lang_name,
langNative: lang.langNative || lang.lang_native,
}));
setActiveLanguages(activeLangs);
}
} catch (error) {
console.error("언어 목록 로드 실패:", error);
}
};
if (isOpen) {
loadLanguages();
}
}, [isOpen]);
// 선택된 라벨의 번역 텍스트 로드
useEffect(() => {
const loadTranslations = async () => {
if (!selectedLabelItem) {
return;
}
// 언어 목록이 아직 로드되지 않았으면 대기
if (activeLanguages.length === 0) {
return;
}
// 이미 이 항목의 번역을 로드한 적이 있으면 스킵
if (allEditedTranslations[selectedLabelItem.id]) {
return;
}
const connectedKey = selectedItems.get(selectedLabelItem.id);
if (!connectedKey?.langKeyId) {
// 키가 연결되지 않은 경우 빈 번역으로 초기화
const defaultTranslations = activeLanguages.reduce((acc, lang) => {
acc[lang.langCode] = lang.langCode === "KR" ? selectedLabelItem.label : "";
return acc;
}, {} as Record<string, string>);
setAllEditedTranslations((prev) => ({
...prev,
[selectedLabelItem.id]: defaultTranslations,
}));
return;
}
setIsLoadingTranslations(true);
try {
const response = await apiClient.get(`/multilang/keys/${connectedKey.langKeyId}/texts`);
if (response.data?.success && response.data?.data) {
const textsMap: Record<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>);
setAllEditedTranslations((prev) => ({
...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);
} finally {
setIsLoadingTranslations(false);
}
};
loadTranslations();
}, [selectedLabelItem, selectedItems, activeLanguages, allEditedTranslations]);
// 현재 항목의 번역 텍스트 변경
const handleTranslationChange = (langCode: string, text: string) => {
if (!selectedLabelItem) return;
const updatedTranslations = {
...(allEditedTranslations[selectedLabelItem.id] || {}),
[langCode]: text,
};
setAllEditedTranslations((prev) => ({
...prev,
[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,
}));
};
// 모든 번역 텍스트 저장 (최종 저장 시)
const handleSaveAllTranslations = async () => {
const savePromises: Promise<void>[] = [];
// 키가 연결된 항목들의 번역만 저장
for (const [itemId, translations] of Object.entries(allEditedTranslations)) {
const connectedKey = selectedItems.get(itemId);
if (!connectedKey?.langKeyId) continue;
const texts = activeLanguages.map((lang) => ({
langCode: lang.langCode,
langText: translations[lang.langCode] || "",
}));
savePromises.push(
apiClient.post(`/multilang/keys/${connectedKey.langKeyId}/texts`, { texts })
.then(() => {})
.catch((error) => {
console.error(`번역 저장 실패 (keyId: ${connectedKey.langKeyId}):`, error);
})
);
}
await Promise.all(savePromises);
};
// 모달 닫힐 때 선택 초기화
useEffect(() => {
if (!isOpen) {
setSelectedLabelItem(null);
setAllEditedTranslations({});
setTranslationFilter("all");
setTranslationStatusCache({});
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] max-w-5xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Languages className="h-5 w-5" />
<Badge variant="secondary" className="ml-2">
{connectedCount} / {totalCount}
</Badge>
</DialogTitle>
</DialogHeader>
{/* 좌우 분할 레이아웃 */}
<div className="flex gap-4" style={{ height: "500px" }}>
{/* 왼쪽: 라벨 목록 */}
<div className="flex w-1/2 flex-col overflow-hidden">
{/* 검색 및 필터 */}
<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>
{/* 라벨 목록 */}
<ScrollArea className="min-h-0 flex-1 rounded-md border">
<div className="p-2">
{groupedLabels.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
{searchText ? "검색 결과가 없습니다." : "다국어 대상이 없습니다."}
</div>
) : (
groupedLabels.map((group) => (
<div key={group.id} className="mb-2">
{/* 그룹 헤더 */}
<button
onClick={() => toggleGroup(group.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted"
>
{group.isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{group.icon}
<span>{group.label}</span>
<Badge variant="outline" className="ml-auto">
{group.items.filter((i) => selectedItems.has(i.id)).length} / {group.items.length}
</Badge>
</button>
{/* 그룹 아이템 */}
{group.isExpanded && (
<div className="ml-6 mt-1 space-y-1">
{group.items.map((item) => {
const isConnected = selectedItems.has(item.id);
const isSelected = selectedLabelItem?.id === item.id;
const translationStatus = getTranslationStatus(item.id);
return (
<div
key={item.id}
onClick={() => setSelectedLabelItem(item)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
isSelected
? "border-primary bg-primary/10 ring-1 ring-primary"
: translationStatus === "complete"
? "border-green-200 bg-green-50 hover:bg-green-100"
: 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">
<TranslationStatusIcon status={translationStatus} />
</div>
{/* 타입 배지 */}
<Badge variant="outline" className="shrink-0 text-xs">
{getTypeLabel(item.type)}
</Badge>
{/* 라벨 텍스트 */}
<span className="flex-1 truncate font-medium">{item.label}</span>
</div>
);
})}
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
</div>
{/* 오른쪽: 선택된 항목 상세 및 번역 편집 */}
<div className="flex w-1/2 flex-col rounded-md border bg-gray-50 p-4">
{selectedLabelItem ? (
<>
{/* 선택된 항목 정보 */}
<div className="mb-4 rounded-md border bg-white p-3">
<div className="mb-2 flex items-center gap-2">
<Badge variant="outline">{getTypeLabel(selectedLabelItem.type)}</Badge>
<span className="font-medium">{selectedLabelItem.label}</span>
</div>
{/* 키 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> :</span>
<KeySelectCombobox
value={
selectedItems.get(selectedLabelItem.id)?.langKeyId &&
selectedItems.get(selectedLabelItem.id)?.langKey
? {
keyId: selectedItems.get(selectedLabelItem.id)!.langKeyId!,
langKey: selectedItems.get(selectedLabelItem.id)!.langKey!,
}
: undefined
}
onChange={(key) => handleKeySelect(selectedLabelItem.id, key)}
langKeys={langKeys}
isLoading={isLoadingKeys}
/>
</div>
</div>
{/* 번역 텍스트 편집 */}
<div className="flex-1 overflow-hidden">
<div className="mb-2">
<h4 className="text-sm font-medium"> </h4>
</div>
{isLoadingTranslations || activeLanguages.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !selectedItems.get(selectedLabelItem.id)?.langKeyId ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
</div>
) : (
<ScrollArea className="h-[280px]">
<div className="space-y-3 pr-4">
{activeLanguages.map((lang) => (
<div key={lang.langCode} className="rounded-md border bg-white p-3">
<div className="mb-1.5 flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{lang.langCode}
</Badge>
<span className="text-xs text-muted-foreground">
{lang.langNative || lang.langName}
</span>
</div>
<Input
value={currentTranslations[lang.langCode] || ""}
onChange={(e) => handleTranslationChange(lang.langCode, e.target.value)}
placeholder={`${lang.langName} 번역 입력...`}
className="h-9 text-sm"
/>
</div>
))}
</div>
</ScrollArea>
)}
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Languages className="mb-2 h-12 w-12 opacity-20" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
)}
</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}>
</Button>
<Button
disabled={isSaving}
onClick={async () => {
setIsSaving(true);
try {
// 1. 번역 텍스트 저장
await handleSaveAllTranslations();
// 2. 컴포넌트 매핑 저장
const updates = Array.from(selectedItems.entries())
.filter(([, data]) => data.langKeyId && data.langKey)
.map(([id, data]) => ({
componentId: id,
langKeyId: data.langKeyId!,
langKey: data.langKey!,
}));
onSave(updates);
onClose();
} catch (error) {
console.error("저장 실패:", error);
} finally {
setIsSaving(false);
}
}}
>
{isSaving ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
...
</>
) : (
"저장"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default MultilangSettingsModal;