498 lines
17 KiB
TypeScript
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;
|
|
|
|
|