1436 lines
49 KiB
TypeScript
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;
|