다국어설정 모달생성

This commit is contained in:
kjs 2026-01-14 11:51:24 +09:00
parent 24315215de
commit c26b346054
3 changed files with 886 additions and 1 deletions

View File

@ -78,6 +78,7 @@ import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel"; import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
@ -145,6 +146,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false); const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
// 🆕 화면에 할당된 메뉴 OBJID // 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined); const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
@ -4375,6 +4377,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isSaving={isSaving} isSaving={isSaving}
onGenerateMultilang={handleGenerateMultilang} onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang} isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
/> />
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */} {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@ -5157,6 +5160,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
screenId={selectedScreen.screenId} screenId={selectedScreen.screenId}
/> />
)} )}
{/* 다국어 설정 모달 */}
<MultilangSettingsModal
isOpen={showMultilangSettingsModal}
onClose={() => setShowMultilangSettingsModal(false)}
components={layout.components}
onSave={(updates) => {
// TODO: 컴포넌트에 langKeyId 저장 로직 구현
console.log("다국어 설정 저장:", updates);
toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`);
}}
/>
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ScreenPreviewProvider> </ScreenPreviewProvider>

View File

@ -0,0 +1,858 @@
"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,
} from "lucide-react";
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;
}
// 입력 가능한 폼 컴포넌트 타입 목록
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;
}
// 그룹화된 라벨 타입
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 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. 버튼 텍스트
if (config?.text && typeof config.text === "string") {
addLabel(comp.id, config.text, "button", parentType, parentLabel);
}
// 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에서 한글 라벨 조회
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
);
}
});
}
// 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]);
// 라벨을 부모 타입별로 그룹화
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) => {
// 검색 필터링
const filteredItems = searchText
? items.filter(
(item) =>
item.label.toLowerCase().includes(searchText.toLowerCase()) ||
item.langKey?.toLowerCase().includes(searchText.toLowerCase())
)
: items;
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]);
// 그룹 펼침/접기 토글
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;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] max-w-3xl">
<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="relative">
<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>
{/* 라벨 목록 */}
<ScrollArea className="h-[400px] 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 connectedKey = selectedItems.get(item.id);
return (
<div
key={item.id}
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
isConnected ? "border-green-200 bg-green-50" : "border-gray-200 bg-white"
)}
>
{/* 상태 아이콘 */}
<div className="shrink-0">
{isConnected ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-gray-400" />
)}
</div>
{/* 타입 배지 */}
<Badge variant="outline" className="shrink-0 text-xs">
{getTypeLabel(item.type)}
</Badge>
{/* 라벨 텍스트 */}
<span className="flex-1 truncate font-medium">{item.label}</span>
{/* 키 선택 콤보박스 */}
<KeySelectCombobox
value={
connectedKey?.langKeyId && connectedKey?.langKey
? { keyId: connectedKey.langKeyId, langKey: connectedKey.langKey }
: undefined
}
onChange={(key) => handleKeySelect(item.id, key)}
langKeys={langKeys}
isLoading={isLoadingKeys}
/>
</div>
);
})}
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
</Button>
<Button
onClick={() => {
// 변경사항 저장
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();
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default MultilangSettingsModal;

View File

@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages } from "lucide-react"; import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react";
import { ScreenResolution } from "@/types/screen"; import { ScreenResolution } from "@/types/screen";
interface SlimToolbarProps { interface SlimToolbarProps {
@ -15,6 +15,7 @@ interface SlimToolbarProps {
onPreview?: () => void; onPreview?: () => void;
onGenerateMultilang?: () => void; onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean; isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
} }
export const SlimToolbar: React.FC<SlimToolbarProps> = ({ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -27,6 +28,7 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onPreview, onPreview,
onGenerateMultilang, onGenerateMultilang,
isGeneratingMultilang = false, isGeneratingMultilang = false,
onOpenMultilangSettings,
}) => { }) => {
return ( return (
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm"> <div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
@ -86,6 +88,17 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span> <span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
</Button> </Button>
)} )}
{onOpenMultilangSettings && (
<Button
variant="outline"
onClick={onOpenMultilangSettings}
className="flex items-center space-x-2"
title="다국어 키 연결 및 설정을 관리합니다"
>
<Settings2 className="h-4 w-4" />
<span> </span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2"> <Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span> <span>{isSaving ? "저장 중..." : "저장"}</span>