ERP-node/frontend/app/(main)/admin/systemMng/i18nList/page.tsx

891 lines
30 KiB
TypeScript
Raw Normal View History

2025-08-21 09:41:46 +09:00
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "@/components/admin/LangKeyModal";
import LanguageModal from "@/components/admin/LanguageModal";
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
import { apiClient } from "@/lib/api/client";
import { LangCategory } from "@/lib/api/multilang";
interface Language {
langCode: string;
langName: string;
langNative: string;
isActive: string;
}
interface LangKey {
keyId: number;
companyCode: string;
menuName: string;
langKey: string;
description: string;
isActive: string;
}
interface LangText {
textId: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
2025-08-21 09:41:46 +09:00
export default function I18nPage() {
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [languages, setLanguages] = useState<Language[]>([]);
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
const [selectedKey, setSelectedKey] = useState<LangKey | null>(null);
const [langTexts, setLangTexts] = useState<LangText[]>([]);
const [editingTexts, setEditingTexts] = useState<LangText[]>([]);
const [selectedCompany, setSelectedCompany] = useState("all");
const [searchText, setSearchText] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingKey, setEditingKey] = useState<LangKey | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<number>>(new Set());
// 언어 관리 관련 상태
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
const [editingLanguage, setEditingLanguage] = useState<Language | null>(null);
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
// 카테고리 관련 상태
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회
const fetchCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
const data = response.data;
if (data.success) {
const companyList = data.data.map((company: any) => ({
code: company.company_code,
name: company.company_name,
}));
setCompanies(companyList);
}
} catch (error) {
// console.error("회사 목록 조회 실패:", error);
}
};
// 언어 목록 조회
const fetchLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
const data = response.data;
if (data.success) {
setLanguages(data.data);
}
} catch (error) {
// console.error("언어 목록 조회 실패:", error);
}
};
// 다국어 키 목록 조회
const fetchLangKeys = async () => {
try {
const response = await apiClient.get("/multilang/keys");
const data = response.data;
if (data.success) {
setLangKeys(data.data);
}
} catch (error) {
// console.error("다국어 키 목록 조회 실패:", error);
}
};
// 필터링된 데이터 계산
const getFilteredLangKeys = () => {
let filteredKeys = langKeys;
// 회사 필터링
if (selectedCompany && selectedCompany !== "all") {
filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
}
// 텍스트 검색 필터링
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
filteredKeys = filteredKeys.filter((key) => {
const langKey = (key.langKey || "").toLowerCase();
const description = (key.description || "").toLowerCase();
const menuName = (key.menuName || "").toLowerCase();
const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
return (
langKey.includes(searchLower) ||
description.includes(searchLower) ||
menuName.includes(searchLower) ||
companyName.includes(searchLower)
);
});
}
return filteredKeys;
};
// 선택된 키의 다국어 텍스트 조회
const fetchLangTexts = async (keyId: number) => {
try {
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
const data = response.data;
if (data.success) {
setLangTexts(data.data);
const editingData = data.data.map((text: LangText) => ({ ...text }));
setEditingTexts(editingData);
}
} catch (error) {
// console.error("다국어 텍스트 조회 실패:", error);
}
};
// 언어 키 선택 처리
const handleKeySelect = (key: LangKey) => {
setSelectedKey(key);
fetchLangTexts(key.keyId);
};
// 텍스트 변경 처리
const handleTextChange = (langCode: string, value: string) => {
const newEditingTexts = [...editingTexts];
const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
if (existingIndex >= 0) {
newEditingTexts[existingIndex].langText = value;
} else {
newEditingTexts.push({
textId: 0,
keyId: selectedKey!.keyId,
langCode: langCode,
langText: value,
isActive: "Y",
});
}
setEditingTexts(newEditingTexts);
};
// 텍스트 저장
const handleSave = async () => {
if (!selectedKey) return;
try {
const requestData = {
texts: editingTexts.map((text) => ({
langCode: text.langCode,
langText: text.langText,
isActive: text.isActive || "Y",
createdBy: user?.userId || "system",
updatedBy: user?.userId || "system",
})),
};
const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
const data = response.data;
if (data.success) {
alert("저장되었습니다.");
fetchLangTexts(selectedKey.keyId);
}
} catch (error) {
alert("저장에 실패했습니다.");
}
};
// 언어 키 추가/수정 모달 열기
const handleAddKey = () => {
setEditingKey(null);
setIsModalOpen(true);
};
// 언어 추가/수정 모달 열기
const handleAddLanguage = () => {
setEditingLanguage(null);
setIsLanguageModalOpen(true);
};
// 언어 수정
const handleEditLanguage = (language: Language) => {
setEditingLanguage(language);
setIsLanguageModalOpen(true);
};
// 언어 저장 (추가/수정)
const handleSaveLanguage = async (languageData: any) => {
try {
const requestData = {
...languageData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingLanguage) {
response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
} else {
response = await apiClient.post("/multilang/languages", requestData);
}
const result = response.data;
if (result.success) {
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
setIsLanguageModalOpen(false);
fetchLanguages();
} else {
alert(`오류: ${result.message}`);
}
} catch (error) {
alert("언어 저장 중 오류가 발생했습니다.");
}
};
// 언어 삭제
const handleDeleteLanguages = async () => {
if (selectedLanguages.size === 0) {
alert("삭제할 언어를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedLanguages).map((langCode) =>
apiClient.delete(`/multilang/languages/${langCode}`),
);
const responses = await Promise.all(deletePromises);
const failedDeletes = responses.filter((response) => !response.data.success);
if (failedDeletes.length === 0) {
alert("선택된 언어가 삭제되었습니다.");
setSelectedLanguages(new Set());
fetchLanguages();
} else {
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
}
} catch (error) {
alert("언어 삭제 중 오류가 발생했습니다.");
}
};
// 언어 선택 체크박스 처리
const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
const newSelected = new Set(selectedLanguages);
if (checked) {
newSelected.add(langCode);
} else {
newSelected.delete(langCode);
}
setSelectedLanguages(newSelected);
};
// 언어 전체 선택/해제
const handleSelectAllLanguages = (checked: boolean) => {
if (checked) {
setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
} else {
setSelectedLanguages(new Set());
}
};
// 언어 키 수정 모달 열기
const handleEditKey = (key: LangKey) => {
setEditingKey(key);
setIsModalOpen(true);
};
// 언어 키 저장 (추가/수정)
const handleSaveKey = async (keyData: any) => {
try {
const requestData = {
...keyData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingKey) {
response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
} else {
response = await apiClient.post("/multilang/keys", requestData);
}
const data = response.data;
if (data.success) {
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
fetchLangKeys();
setIsModalOpen(false);
} else {
if (data.message && data.message.includes("이미 존재하는 언어키")) {
alert(data.message);
} else {
alert(data.message || "언어 키 저장에 실패했습니다.");
}
}
} catch (error) {
alert("언어 키 저장에 실패했습니다.");
}
};
// 체크박스 선택/해제
const handleCheckboxChange = (keyId: number, checked: boolean) => {
const newSelectedKeys = new Set(selectedKeys);
if (checked) {
newSelectedKeys.add(keyId);
} else {
newSelectedKeys.delete(keyId);
}
setSelectedKeys(newSelectedKeys);
};
// 키 상태 토글
const handleToggleStatus = async (keyId: number) => {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
const data = response.data;
if (data.success) {
alert(`키가 ${data.data}되었습니다.`);
fetchLangKeys();
} else {
alert("상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
alert("키 상태 변경 중 오류가 발생했습니다.");
}
};
// 언어 상태 토글
const handleToggleLanguageStatus = async (langCode: string) => {
try {
const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
const data = response.data;
if (data.success) {
alert(`언어가 ${data.data}되었습니다.`);
fetchLanguages();
} else {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
};
// 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
setSelectedKeys(new Set(allKeyIds));
} else {
setSelectedKeys(new Set());
}
};
// 선택된 키들 일괄 삭제
const handleDeleteSelectedKeys = async () => {
if (selectedKeys.size === 0) {
alert("삭제할 키를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
const responses = await Promise.all(deletePromises);
const allSuccess = responses.every((response) => response.data.success);
if (allSuccess) {
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
setSelectedKeys(new Set());
fetchLangKeys();
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
handleCancel();
}
} else {
alert("일부 키 삭제에 실패했습니다.");
}
} catch (error) {
alert("선택된 키 삭제에 실패했습니다.");
}
};
// 개별 키 삭제
const handleDeleteKey = async (keyId: number) => {
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.")) {
return;
}
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
const data = response.data;
if (data.success) {
alert("언어 키가 영구적으로 삭제되었습니다.");
fetchLangKeys();
if (selectedKey && selectedKey.keyId === keyId) {
handleCancel();
}
}
} catch (error) {
alert("언어 키 삭제에 실패했습니다.");
}
};
// 취소 처리
const handleCancel = () => {
setSelectedKey(null);
setLangTexts([]);
setEditingTexts([]);
};
useEffect(() => {
const initializeData = async () => {
setLoading(true);
await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
setLoading(false);
};
initializeData();
}, []);
const columns = [
{
id: "select",
header: () => {
const filteredKeys = getFilteredLangKeys();
return (
<input
type="checkbox"
checked={selectedKeys.size === filteredKeys.length && filteredKeys.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="h-4 w-4"
/>
);
},
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedKeys.has(row.original.keyId)}
onChange={(e) => handleCheckboxChange(row.original.keyId, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "companyCode",
header: "회사",
cell: ({ row }: any) => {
const companyName =
row.original.companyCode === "*"
? "공통"
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
},
},
{
accessorKey: "menuName",
header: "메뉴명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
),
},
{
accessorKey: "langKey",
header: "언어 키",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditKey(row.original)}
>
{row.original.langKey}
</div>
),
},
{
accessorKey: "description",
header: "설명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleStatus(row.original.keyId)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
// 언어 테이블 컬럼 정의
const languageColumns = [
{
id: "select",
header: () => (
<input
type="checkbox"
checked={selectedLanguages.size === languages.length && languages.length > 0}
onChange={(e) => handleSelectAllLanguages(e.target.checked)}
className="h-4 w-4"
/>
),
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedLanguages.has(row.original.langCode)}
onChange={(e) => handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "langCode",
header: "언어 코드",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditLanguage(row.original)}
>
{row.original.langCode}
</div>
),
},
{
accessorKey: "langName",
header: "언어명 (영문)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
),
},
{
accessorKey: "langNative",
header: "언어명 (원어)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
if (loading) {
return <LoadingSpinner />;
}
return (
<div className="min-h-screen bg-gray-50">
2025-09-25 14:22:30 +09:00
<div className="w-full max-w-none px-4 py-8">
<div className="container mx-auto p-2">
{/* 탭 네비게이션 */}
<div className="flex space-x-1 border-b">
<button
onClick={() => setActiveTab("keys")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
<button
onClick={() => setActiveTab("languages")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
</div>
{/* 메인 콘텐츠 영역 */}
<div className="mt-2">
{/* 언어 관리 탭 */}
{activeTab === "languages" && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
({selectedLanguages.size})
</Button>
)}
<Button onClick={handleAddLanguage}> </Button>
</div>
</div>
<DataTable data={languages} columns={languageColumns} searchable />
</CardContent>
</Card>
)}
{/* 다국어 키 관리 탭 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* 좌측: 카테고리 트리 (2/12) */}
<Card className="lg:col-span-2">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"></CardTitle>
</div>
</CardHeader>
<CardContent className="p-2">
<ScrollArea className="h-[500px]">
<CategoryTree
selectedCategoryId={selectedCategory?.categoryId || null}
onSelectCategory={(cat) => setSelectedCategory(cat)}
onDoubleClickCategory={(cat) => {
setSelectedCategory(cat);
setIsGenerateModalOpen(true);
}}
/>
</ScrollArea>
</CardContent>
</Card>
{/* 중앙: 언어 키 목록 (6/12) */}
<Card className="lg:col-span-6">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedCategory && (
<Badge variant="secondary" className="ml-2">
{selectedCategory.categoryName}
</Badge>
)}
</CardTitle>
<div className="flex space-x-2">
<Button
size="sm"
variant="destructive"
onClick={handleDeleteSelectedKeys}
disabled={selectedKeys.size === 0}
>
({selectedKeys.size})
</Button>
<Button size="sm" variant="outline" onClick={handleAddKey}>
</Button>
<Button
size="sm"
onClick={() => setIsGenerateModalOpen(true)}
disabled={!selectedCategory}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company" className="text-xs"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="search" className="text-xs"></Label>
<Input
placeholder="키명, 설명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-end">
<div className="text-xs text-muted-foreground">: {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
searchable={false}
onRowClick={handleKeySelect}
/>
</div>
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
<Card className="lg:col-span-4">
<CardHeader>
<CardTitle>
{selectedKey ? (
<>
:{" "}
<Badge variant="secondary" className="ml-2">
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
</Badge>
</>
) : (
"다국어 편집"
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedKey ? (
<div>
{/* 스크롤 가능한 텍스트 영역 */}
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
{languages
.filter((lang) => lang.isActive === "Y")
.map((lang) => {
const text = editingTexts.find((t) => t.langCode === lang.langCode);
return (
<div key={lang.langCode} className="flex items-center space-x-4">
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
{lang.langName}
</Badge>
<Input
placeholder={`${lang.langName} 텍스트 입력`}
value={text?.langText || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
className="flex-1"
/>
</div>
);
})}
</div>
{/* 저장 버튼 - 고정 위치 */}
<div className="mt-4 flex space-x-2 border-t pt-4">
<Button onClick={handleSave}></Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
</div>
</div>
) : (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
{/* 언어 키 추가/수정 모달 */}
<LangKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveKey}
keyData={editingKey}
companies={companies}
/>
{/* 언어 추가/수정 모달 */}
<LanguageModal
isOpen={isLanguageModalOpen}
onClose={() => setIsLanguageModalOpen(false)}
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
{/* 키 자동 생성 모달 */}
<KeyGenerateModal
isOpen={isGenerateModalOpen}
onClose={() => setIsGenerateModalOpen(false)}
selectedCategory={selectedCategory}
companyCode={user?.companyCode || ""}
isSuperAdmin={user?.companyCode === "*"}
onSuccess={() => {
fetchLangKeys();
}}
/>
</div>
</div>
</div>
);
2025-08-21 09:41:46 +09:00
}