ERP-node/frontend/components/admin/multilang/KeyGenerateModal.tsx

498 lines
17 KiB
TypeScript

"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;