diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 2fbbe7c5..1614c9b8 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -463,7 +463,8 @@ select {
left: 0;
right: 0;
bottom: 0;
- background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
pointer-events: none;
@@ -471,18 +472,24 @@ select {
}
.pop-light .pop-bg-pattern::before {
- background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
}
/* POP 글로우 효과 */
.pop-glow-cyan {
- box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
+ box-shadow:
+ 0 0 20px rgba(0, 212, 255, 0.5),
+ 0 0 40px rgba(0, 212, 255, 0.3);
}
.pop-glow-cyan-strong {
- box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
+ box-shadow:
+ 0 0 10px rgba(0, 212, 255, 0.8),
+ 0 0 30px rgba(0, 212, 255, 0.5),
+ 0 0 50px rgba(0, 212, 255, 0.3);
}
.pop-glow-success {
@@ -504,7 +511,9 @@ select {
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
}
50% {
- box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
+ box-shadow:
+ 0 0 20px rgba(0, 212, 255, 0.8),
+ 0 0 30px rgba(0, 212, 255, 0.4);
}
}
@@ -610,4 +619,18 @@ select {
animation: marching-ants-v 0.4s linear infinite;
}
+/* ===== 저장 테이블 막대기 애니메이션 ===== */
+@keyframes saveBarDrop {
+ 0% {
+ transform: scaleY(0);
+ transform-origin: top;
+ opacity: 0;
+ }
+ 100% {
+ transform: scaleY(1);
+ transform-origin: top;
+ opacity: 1;
+ }
+}
+
/* ===== End of Global Styles ===== */
diff --git a/frontend/components/admin/multilang/CategoryTree.tsx b/frontend/components/admin/multilang/CategoryTree.tsx
new file mode 100644
index 00000000..2e1238cf
--- /dev/null
+++ b/frontend/components/admin/multilang/CategoryTree.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { LangCategory, getCategories } from "@/lib/api/multilang";
+
+interface CategoryTreeProps {
+ selectedCategoryId: number | null;
+ onSelectCategory: (category: LangCategory | null) => void;
+ onDoubleClickCategory?: (category: LangCategory) => void;
+}
+
+interface CategoryNodeProps {
+ category: LangCategory;
+ level: number;
+ selectedCategoryId: number | null;
+ onSelectCategory: (category: LangCategory) => void;
+ onDoubleClickCategory?: (category: LangCategory) => void;
+}
+
+function CategoryNode({
+ category,
+ level,
+ selectedCategoryId,
+ onSelectCategory,
+ onDoubleClickCategory,
+}: CategoryNodeProps) {
+ // 기본값: 접힌 상태로 시작
+ const [isExpanded, setIsExpanded] = useState(false);
+ const hasChildren = category.children && category.children.length > 0;
+ const isSelected = selectedCategoryId === category.categoryId;
+
+ return (
+
+
onSelectCategory(category)}
+ onDoubleClick={() => onDoubleClickCategory?.(category)}
+ >
+ {/* 확장/축소 아이콘 */}
+ {hasChildren ? (
+ {
+ e.stopPropagation();
+ setIsExpanded(!isExpanded);
+ }}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {/* 폴더/태그 아이콘 */}
+ {hasChildren || level === 0 ? (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+ {/* 카테고리 이름 */}
+ {category.categoryName}
+
+ {/* prefix 표시 */}
+
+ {category.keyPrefix}
+
+
+
+ {/* 자식 카테고리 */}
+ {hasChildren && isExpanded && (
+
+ {category.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function CategoryTree({
+ selectedCategoryId,
+ onSelectCategory,
+ onDoubleClickCategory,
+}: CategoryTreeProps) {
+ const [categories, setCategories] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadCategories();
+ }, []);
+
+ const loadCategories = async () => {
+ try {
+ setLoading(true);
+ const response = await getCategories();
+ if (response.success && response.data) {
+ setCategories(response.data);
+ } else {
+ setError(response.error?.details || "카테고리 로드 실패");
+ }
+ } catch (err) {
+ setError("카테고리 로드 중 오류 발생");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (categories.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* 전체 선택 옵션 */}
+
onSelectCategory(null)}
+ >
+
+ 전체
+
+
+ {/* 카테고리 트리 */}
+ {categories.map((category) => (
+
+ ))}
+
+ );
+}
+
+export default CategoryTree;
+
+
diff --git a/frontend/components/admin/multilang/KeyGenerateModal.tsx b/frontend/components/admin/multilang/KeyGenerateModal.tsx
new file mode 100644
index 00000000..c595adbc
--- /dev/null
+++ b/frontend/components/admin/multilang/KeyGenerateModal.tsx
@@ -0,0 +1,497 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ LangCategory,
+ Language,
+ generateKey,
+ previewKey,
+ createOverrideKey,
+ getLanguages,
+ getCategoryPath,
+ KeyPreview,
+} from "@/lib/api/multilang";
+import { apiClient } from "@/lib/api/client";
+
+interface Company {
+ companyCode: string;
+ companyName: string;
+}
+
+interface KeyGenerateModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ selectedCategory: LangCategory | null;
+ companyCode: string;
+ isSuperAdmin: boolean;
+ onSuccess: () => void;
+}
+
+export function KeyGenerateModal({
+ isOpen,
+ onClose,
+ selectedCategory,
+ companyCode,
+ isSuperAdmin,
+ onSuccess,
+}: KeyGenerateModalProps) {
+ // 상태
+ const [keyMeaning, setKeyMeaning] = useState("");
+ const [usageNote, setUsageNote] = useState("");
+ const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode);
+ const [languages, setLanguages] = useState([]);
+ const [texts, setTexts] = useState>({});
+ const [categoryPath, setCategoryPath] = useState([]);
+ const [preview, setPreview] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [companies, setCompanies] = useState([]);
+ const [companySearchOpen, setCompanySearchOpen] = useState(false);
+
+ // 초기화
+ useEffect(() => {
+ if (isOpen) {
+ setKeyMeaning("");
+ setUsageNote("");
+ setTargetCompanyCode(isSuperAdmin ? "*" : companyCode);
+ setTexts({});
+ setPreview(null);
+ setError(null);
+ loadLanguages();
+ if (isSuperAdmin) {
+ loadCompanies();
+ }
+ if (selectedCategory) {
+ loadCategoryPath(selectedCategory.categoryId);
+ } else {
+ setCategoryPath([]);
+ }
+ }
+ }, [isOpen, selectedCategory, companyCode, isSuperAdmin]);
+
+ // 회사 목록 로드 (최고관리자 전용)
+ const loadCompanies = async () => {
+ try {
+ const response = await apiClient.get("/admin/companies");
+ if (response.data.success && response.data.data) {
+ // snake_case를 camelCase로 변환하고 공통(*)은 제외
+ const companyList = response.data.data
+ .filter((c: any) => c.company_code !== "*")
+ .map((c: any) => ({
+ companyCode: c.company_code,
+ companyName: c.company_name,
+ }));
+ setCompanies(companyList);
+ }
+ } catch (err) {
+ console.error("회사 목록 로드 실패:", err);
+ }
+ };
+
+ // 언어 목록 로드
+ const loadLanguages = async () => {
+ const response = await getLanguages();
+ if (response.success && response.data) {
+ const activeLanguages = response.data.filter((l) => l.isActive === "Y");
+ setLanguages(activeLanguages);
+ // 초기 텍스트 상태 설정
+ const initialTexts: Record = {};
+ activeLanguages.forEach((lang) => {
+ initialTexts[lang.langCode] = "";
+ });
+ setTexts(initialTexts);
+ }
+ };
+
+ // 카테고리 경로 로드
+ const loadCategoryPath = async (categoryId: number) => {
+ const response = await getCategoryPath(categoryId);
+ if (response.success && response.data) {
+ setCategoryPath(response.data);
+ }
+ };
+
+ // 키 미리보기 (디바운스)
+ const loadPreview = useCallback(async () => {
+ if (!selectedCategory || !keyMeaning.trim()) {
+ setPreview(null);
+ return;
+ }
+
+ setPreviewLoading(true);
+ try {
+ const response = await previewKey(
+ selectedCategory.categoryId,
+ keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
+ targetCompanyCode
+ );
+ if (response.success && response.data) {
+ setPreview(response.data);
+ }
+ } catch (err) {
+ console.error("키 미리보기 실패:", err);
+ } finally {
+ setPreviewLoading(false);
+ }
+ }, [selectedCategory, keyMeaning, targetCompanyCode]);
+
+ // keyMeaning 변경 시 디바운스로 미리보기 로드
+ useEffect(() => {
+ const timer = setTimeout(loadPreview, 500);
+ return () => clearTimeout(timer);
+ }, [loadPreview]);
+
+ // 텍스트 변경 핸들러
+ const handleTextChange = (langCode: string, value: string) => {
+ setTexts((prev) => ({ ...prev, [langCode]: value }));
+ };
+
+ // 저장 핸들러
+ const handleSave = async () => {
+ if (!selectedCategory) {
+ setError("카테고리를 선택해주세요");
+ return;
+ }
+
+ if (!keyMeaning.trim()) {
+ setError("키 의미를 입력해주세요");
+ return;
+ }
+
+ // 최소 하나의 텍스트 입력 검증
+ const hasText = Object.values(texts).some((t) => t.trim());
+ if (!hasText) {
+ setError("최소 하나의 언어에 대한 텍스트를 입력해주세요");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ // 오버라이드 모드인지 확인
+ if (preview?.isOverride && preview.baseKeyId) {
+ // 오버라이드 키 생성
+ const response = await createOverrideKey({
+ companyCode: targetCompanyCode,
+ baseKeyId: preview.baseKeyId,
+ texts: Object.entries(texts)
+ .filter(([_, text]) => text.trim())
+ .map(([langCode, langText]) => ({ langCode, langText })),
+ });
+
+ if (response.success) {
+ onSuccess();
+ onClose();
+ } else {
+ setError(response.error?.details || "오버라이드 키 생성 실패");
+ }
+ } else {
+ // 새 키 생성
+ const response = await generateKey({
+ companyCode: targetCompanyCode,
+ categoryId: selectedCategory.categoryId,
+ keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
+ usageNote: usageNote.trim() || undefined,
+ texts: Object.entries(texts)
+ .filter(([_, text]) => text.trim())
+ .map(([langCode, langText]) => ({ langCode, langText })),
+ });
+
+ if (response.success) {
+ onSuccess();
+ onClose();
+ } else {
+ setError(response.error?.details || "키 생성 실패");
+ }
+ }
+ } catch (err: any) {
+ setError(err.message || "키 생성 중 오류 발생");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 생성될 키 미리보기
+ const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim()
+ ? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".")
+ : "";
+
+ return (
+ !open && onClose()}>
+
+
+
+ {preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"}
+
+
+ {preview?.isOverride
+ ? "공통 키에 대한 회사별 오버라이드를 생성합니다"
+ : "새로운 다국어 키를 자동으로 생성합니다"}
+
+
+
+
+ {/* 카테고리 경로 표시 */}
+
+
카테고리
+
+ {categoryPath.length > 0 ? (
+ categoryPath.map((cat, idx) => (
+
+
+ {cat.categoryName}
+
+ {idx < categoryPath.length - 1 && (
+ /
+ )}
+
+ ))
+ ) : (
+
+ 카테고리를 선택해주세요
+
+ )}
+
+
+
+ {/* 키 의미 입력 */}
+
+
+ 키 의미 *
+
+
setKeyMeaning(e.target.value)}
+ placeholder="예: add_new_item, search_button, save_success"
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+
+ 영문 소문자와 밑줄(_)을 사용하세요
+
+
+
+ {/* 생성될 키 미리보기 */}
+ {generatedKeyPreview && (
+
+
+ {previewLoading ? (
+
+ ) : preview?.exists ? (
+
+ ) : preview?.isOverride ? (
+
+ ) : (
+
+ )}
+
+ {generatedKeyPreview}
+
+
+ {preview?.exists && (
+
+ 이미 존재하는 키입니다
+
+ )}
+ {preview?.isOverride && !preview?.exists && (
+
+ 공통 키가 존재합니다. 회사별 오버라이드로 생성됩니다.
+
+ )}
+
+ )}
+
+ {/* 대상 회사 선택 (최고 관리자만) */}
+ {isSuperAdmin && (
+
+
대상
+
+
+
+
+ {targetCompanyCode === "*"
+ ? "공통 (*) - 모든 회사 적용"
+ : companies.find((c) => c.companyCode === targetCompanyCode)
+ ? `${companies.find((c) => c.companyCode === targetCompanyCode)?.companyName} (${targetCompanyCode})`
+ : "대상 선택"}
+
+
+
+
+
+
+
+
+ 검색 결과가 없습니다
+
+
+ {
+ setTargetCompanyCode("*");
+ setCompanySearchOpen(false);
+ }}
+ className="text-xs sm:text-sm"
+ >
+
+ 공통 (*) - 모든 회사 적용
+
+ {companies.map((company) => (
+ {
+ setTargetCompanyCode(company.companyCode);
+ setCompanySearchOpen(false);
+ }}
+ className="text-xs sm:text-sm"
+ >
+
+ {company.companyName} ({company.companyCode})
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ {/* 사용 메모 */}
+
+
+ 사용 메모 (선택)
+
+
+
+ {/* 번역 텍스트 입력 */}
+
+
번역 텍스트 *
+
+ {languages.map((lang) => (
+
+
+ {lang.langCode}
+
+ handleTextChange(lang.langCode, e.target.value)}
+ placeholder={`${lang.langName} 텍스트`}
+ className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
+ />
+
+ ))}
+
+
+
+ {/* 에러 메시지 */}
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+
+
+
+ 취소
+
+
+ {loading ? (
+ <>
+
+ 생성 중...
+ >
+ ) : preview?.isOverride ? (
+ "오버라이드 생성"
+ ) : (
+ "키 생성"
+ )}
+
+
+
+
+ );
+}
+
+export default KeyGenerateModal;
+
+
diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx
index 3686554f..9c4ad7e8 100644
--- a/frontend/components/dataflow/node-editor/FlowEditor.tsx
+++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx
@@ -65,6 +65,10 @@ const nodeTypes = {
*/
interface FlowEditorInnerProps {
initialFlowId?: number | null;
+ /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
+ onSaveComplete?: (flowId: number, flowName: string) => void;
+ /** 임베디드 모드 여부 */
+ embedded?: boolean;
}
// 플로우 에디터 툴바 버튼 설정
@@ -87,7 +91,7 @@ const flowToolbarButtons: ToolbarButton[] = [
},
];
-function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
+function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
const reactFlowWrapper = useRef(null);
const { screenToFlowPosition, setCenter } = useReactFlow();
@@ -385,7 +389,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
{/* 상단 툴바 */}
-
+
@@ -416,13 +420,21 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
*/
interface FlowEditorProps {
initialFlowId?: number | null;
+ /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
+ onSaveComplete?: (flowId: number, flowName: string) => void;
+ /** 임베디드 모드 여부 (헤더 표시 여부 등) */
+ embedded?: boolean;
}
-export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
+export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
return (
);
diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx
index d837d355..f136d216 100644
--- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx
+++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx
@@ -17,9 +17,11 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
+ /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
+ onSaveComplete?: (flowId: number, flowName: string) => void;
}
-export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
+export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
@@ -59,13 +61,27 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const result = await saveFlow();
if (result.success) {
toast({
- title: "✅ 플로우 저장 완료",
+ title: "저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default",
});
+
+ // 임베디드 모드에서 저장 완료 콜백 호출
+ if (onSaveComplete && result.flowId) {
+ onSaveComplete(result.flowId, flowName);
+ }
+
+ // 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
+ if (window.opener && result.flowId) {
+ window.opener.postMessage({
+ type: "FLOW_SAVED",
+ flowId: result.flowId,
+ flowName: flowName,
+ }, "*");
+ }
} else {
toast({
- title: "❌ 저장 실패",
+ title: "저장 실패",
description: result.message,
variant: "destructive",
});
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx
index 236071ac..e3e8d920 100644
--- a/frontend/components/layout/AppLayout.tsx
+++ b/frontend/components/layout/AppLayout.tsx
@@ -302,6 +302,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
+ // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
+ const isPreviewMode = searchParams.get("preview") === "true";
+
// 현재 모드에 따라 표시할 메뉴 결정
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
const currentMenus = isAdminMode ? adminMenus : userMenus;
@@ -458,6 +461,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
+ // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
+ if (isPreviewMode) {
+ return (
+
+ );
+ }
+
// UI 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx
index ad23acb4..a74501e0 100644
--- a/frontend/components/layout/ProfileModal.tsx
+++ b/frontend/components/layout/ProfileModal.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -15,6 +16,14 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
import { ProfileFormData } from "@/types/profile";
import { Separator } from "@/components/ui/separator";
import { VehicleRegisterData } from "@/lib/api/driver";
+import { apiClient } from "@/lib/api/client";
+
+// 언어 정보 타입
+interface LanguageInfo {
+ langCode: string;
+ langName: string;
+ langNative: string;
+}
// 운전자 정보 타입
export interface DriverInfo {
@@ -148,6 +157,46 @@ export function ProfileModal({
onSave,
onAlertClose,
}: ProfileModalProps) {
+ // 언어 목록 상태
+ const [languages, setLanguages] = useState