다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가
This commit is contained in:
497
frontend/components/admin/multilang/KeyGenerateModal.tsx
Normal file
497
frontend/components/admin/multilang/KeyGenerateModal.tsx
Normal file
@@ -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<Language[]>([]);
|
||||
const [texts, setTexts] = useState<Record<string, string>>({});
|
||||
const [categoryPath, setCategoryPath] = useState<LangCategory[]>([]);
|
||||
const [preview, setPreview] = useState<KeyPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{preview?.isOverride
|
||||
? "공통 키에 대한 회사별 오버라이드를 생성합니다"
|
||||
: "새로운 다국어 키를 자동으로 생성합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 카테고리 경로 표시 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">카테고리</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{categoryPath.length > 0 ? (
|
||||
categoryPath.map((cat, idx) => (
|
||||
<span key={cat.categoryId} className="flex items-center">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{cat.categoryName}
|
||||
</Badge>
|
||||
{idx < categoryPath.length - 1 && (
|
||||
<span className="mx-1 text-muted-foreground">/</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
카테고리를 선택해주세요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 키 의미 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="keyMeaning" className="text-xs sm:text-sm">
|
||||
키 의미 *
|
||||
</Label>
|
||||
<Input
|
||||
id="keyMeaning"
|
||||
value={keyMeaning}
|
||||
onChange={(e) => setKeyMeaning(e.target.value)}
|
||||
placeholder="예: add_new_item, search_button, save_success"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
영문 소문자와 밑줄(_)을 사용하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 생성될 키 미리보기 */}
|
||||
{generatedKeyPreview && (
|
||||
<div className={cn(
|
||||
"rounded-md border p-3",
|
||||
preview?.exists
|
||||
? "border-destructive bg-destructive/10"
|
||||
: preview?.isOverride
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-green-500 bg-green-500/10"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{previewLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : preview?.exists ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : preview?.isOverride ? (
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<code className="text-xs font-mono sm:text-sm">
|
||||
{generatedKeyPreview}
|
||||
</code>
|
||||
</div>
|
||||
{preview?.exists && (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
이미 존재하는 키입니다
|
||||
</p>
|
||||
)}
|
||||
{preview?.isOverride && !preview?.exists && (
|
||||
<p className="mt-1 text-xs text-blue-600">
|
||||
공통 키가 존재합니다. 회사별 오버라이드로 생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 대상 회사 선택 (최고 관리자만) */}
|
||||
{isSuperAdmin && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">대상</Label>
|
||||
<div className="mt-1">
|
||||
<Popover open={companySearchOpen} onOpenChange={setCompanySearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={companySearchOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{targetCompanyCode === "*"
|
||||
? "공통 (*) - 모든 회사 적용"
|
||||
: companies.find((c) => c.companyCode === targetCompanyCode)
|
||||
? `${companies.find((c) => c.companyCode === targetCompanyCode)?.companyName} (${targetCompanyCode})`
|
||||
: "대상 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs sm:text-sm">
|
||||
검색 결과가 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="공통"
|
||||
onSelect={() => {
|
||||
setTargetCompanyCode("*");
|
||||
setCompanySearchOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetCompanyCode === "*" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
공통 (*) - 모든 회사 적용
|
||||
</CommandItem>
|
||||
{companies.map((company) => (
|
||||
<CommandItem
|
||||
key={company.companyCode}
|
||||
value={`${company.companyName} ${company.companyCode}`}
|
||||
onSelect={() => {
|
||||
setTargetCompanyCode(company.companyCode);
|
||||
setCompanySearchOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetCompanyCode === company.companyCode ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{company.companyName} ({company.companyCode})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용 메모 */}
|
||||
<div>
|
||||
<Label htmlFor="usageNote" className="text-xs sm:text-sm">
|
||||
사용 메모 (선택)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="usageNote"
|
||||
value={usageNote}
|
||||
onChange={(e) => setUsageNote(e.target.value)}
|
||||
placeholder="이 키가 어디서 사용되는지 메모"
|
||||
className="h-16 resize-none text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 번역 텍스트 입력 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">번역 텍스트 *</Label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.langCode} className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="w-12 justify-center text-xs">
|
||||
{lang.langCode}
|
||||
</Badge>
|
||||
<Input
|
||||
value={texts[lang.langCode] || ""}
|
||||
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
|
||||
placeholder={`${lang.langName} 텍스트`}
|
||||
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs sm:text-sm">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading || !selectedCategory || !keyMeaning.trim() || preview?.exists}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : preview?.isOverride ? (
|
||||
"오버라이드 생성"
|
||||
) : (
|
||||
"키 생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyGenerateModal;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user