ERP-node/frontend/components/admin/ComponentFormModal.tsx

566 lines
20 KiB
TypeScript
Raw Normal View History

2025-09-10 14:09:32 +09:00
"use client";
import React, { useState, useEffect } from "react";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react";
import { toast } from "sonner";
import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck";
import { Alert, AlertDescription } from "@/components/ui/alert";
// 컴포넌트 카테고리 정의
const COMPONENT_CATEGORIES = [
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" },
{ id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" },
{ id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" },
];
// 컴포넌트 타입 정의
const COMPONENT_TYPES = [
{ id: "widget", name: "위젯", description: "입력 양식 위젯" },
{ id: "button", name: "버튼", description: "액션 버튼" },
{ id: "card", name: "카드", description: "카드 컨테이너" },
{ id: "container", name: "컨테이너", description: "일반 컨테이너" },
{ id: "dashboard", name: "대시보드", description: "대시보드 그리드" },
{ id: "alert", name: "알림", description: "알림 메시지" },
{ id: "badge", name: "배지", description: "상태 배지" },
{ id: "progress", name: "진행률", description: "진행률 표시" },
{ id: "chart", name: "차트", description: "데이터 차트" },
];
// 웹타입 정의 (위젯인 경우만)
const WEB_TYPES = [
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"boolean",
"checkbox",
"radio",
"code",
"entity",
"file",
"email",
"tel",
"color",
"range",
"time",
"week",
"month",
];
interface ComponentFormData {
component_code: string;
component_name: string;
description: string;
category: string;
component_config: {
type: string;
webType?: string;
config_panel?: string;
};
default_size: {
width: number;
height: number;
};
icon_name: string;
active: string;
sort_order: number;
}
interface ComponentFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: ComponentFormData) => Promise<void>;
initialData?: any;
mode?: "create" | "edit";
}
export const ComponentFormModal: React.FC<ComponentFormModalProps> = ({
isOpen,
onClose,
onSubmit,
initialData,
mode = "create",
}) => {
const [formData, setFormData] = useState<ComponentFormData>({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false);
// 중복 체크 쿼리 (생성 모드에서만 활성화)
const duplicateCheck = useComponentDuplicateCheck(
formData.component_code,
mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0,
);
// 초기 데이터 설정
useEffect(() => {
if (isOpen) {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
// 새 컴포넌트 생성 시 초기값
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
}
}, [isOpen, mode, initialData]);
// 컴포넌트 코드 자동 생성
const generateComponentCode = (name: string, type: string) => {
if (!name) return "";
// 한글을 영문으로 매핑
const koreanToEnglish: { [key: string]: string } = {
: "help",
: "tooltip",
: "guide",
: "alert",
: "button",
: "card",
: "dashboard",
: "panel",
: "input",
: "text",
: "select",
: "check",
: "radio",
: "file",
: "image",
: "table",
: "list",
: "form",
};
// 한글을 영문으로 변환
let englishName = name;
Object.entries(koreanToEnglish).forEach(([korean, english]) => {
englishName = englishName.replace(new RegExp(korean, "g"), english);
});
const cleanName = englishName
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정
const finalName = cleanName || "component";
const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName;
return type === "widget" ? validName : `${validName}-${type}`;
};
// 폼 필드 변경 처리
const handleChange = (field: string, value: any) => {
setFormData((prev) => {
const newData = { ...prev };
if (field.includes(".")) {
const [parent, child] = field.split(".");
newData[parent as keyof ComponentFormData] = {
...(newData[parent as keyof ComponentFormData] as any),
[child]: value,
};
} else {
(newData as any)[field] = value;
}
// 컴포넌트 이름이 변경되면 코드 자동 생성
if (field === "component_name" || field === "component_config.type") {
const name = field === "component_name" ? value : newData.component_name;
const type = field === "component_config.type" ? value : newData.component_config.type;
if (name && mode === "create") {
newData.component_code = generateComponentCode(name, type);
// 자동 생성된 코드에 대해서도 중복 체크 활성화
setShouldCheckDuplicate(true);
}
}
// 컴포넌트 코드가 직접 변경되면 중복 체크 활성화
if (field === "component_code" && mode === "create") {
setShouldCheckDuplicate(true);
}
return newData;
});
};
// 폼 제출
const handleSubmit = async () => {
// 유효성 검사
if (!formData.component_code || !formData.component_name) {
toast.error("컴포넌트 코드와 이름은 필수입니다.");
return;
}
if (!formData.component_config.type) {
toast.error("컴포넌트 타입을 선택해주세요.");
return;
}
// 생성 모드에서 중복 체크
if (mode === "create" && duplicateCheck.data?.isDuplicate) {
toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요.");
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다.");
onClose();
} catch (error) {
toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다.");
} finally {
setIsSubmitting(false);
}
};
// 폼 초기화
const handleReset = () => {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다."
: "선택한 컴포넌트의 정보를 수정합니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component_name"> *</Label>
<Input
id="component_name"
value={formData.component_name}
onChange={(e) => handleChange("component_name", e.target.value)}
placeholder="예: 정보 알림"
/>
</div>
<div>
<Label htmlFor="component_code"> *</Label>
<div className="relative">
<Input
id="component_code"
value={formData.component_code}
onChange={(e) => handleChange("component_code", e.target.value)}
placeholder="예: alert-info"
disabled={mode === "edit"}
className={
mode === "create" && duplicateCheck.data?.isDuplicate
? "border-red-500 pr-10"
: mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate
? "border-green-500 pr-10"
: ""
}
/>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
{duplicateCheck.data.isDuplicate ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</div>
)}
</div>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<Alert
className={`mt-2 ${duplicateCheck.data.isDuplicate ? "border-red-200 bg-red-50" : "border-green-200 bg-green-50"}`}
>
<AlertDescription className={duplicateCheck.data.isDuplicate ? "text-red-700" : "text-green-700"}>
{duplicateCheck.data.isDuplicate
? "⚠️ 이미 사용 중인 컴포넌트 코드입니다."
: "✅ 사용 가능한 컴포넌트 코드입니다."}
</AlertDescription>
</Alert>
)}
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="컴포넌트에 대한 설명을 입력하세요"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="icon_name"> </Label>
<Input
id="icon_name"
value={formData.icon_name}
onChange={(e) => handleChange("icon_name", e.target.value)}
placeholder="예: info, alert-triangle"
/>
</div>
</div>
</CardContent>
</Card>
{/* 컴포넌트 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.component_config.type}
onValueChange={(value) => handleChange("component_config.type", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_TYPES.map((type) => (
<SelectItem key={type.id} value={type.id}>
<div>
<div className="font-medium">{type.name}</div>
<div className="text-xs text-gray-500">{type.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 위젯인 경우 웹타입 선택 */}
{formData.component_config.type === "widget" && (
<div>
<Label></Label>
<Select
value={formData.component_config.webType || ""}
onValueChange={(value) => handleChange("component_config.webType", value)}
>
<SelectTrigger>
<SelectValue placeholder="웹타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{WEB_TYPES.map((webType) => (
<SelectItem key={webType} value={webType}>
{webType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="config_panel"> </Label>
<Input
id="config_panel"
value={formData.component_config.config_panel || ""}
onChange={(e) => handleChange("component_config.config_panel", e.target.value)}
placeholder="예: AlertConfigPanel"
/>
</div>
</CardContent>
</Card>
{/* 기본 크기 및 기타 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="width"> (px)</Label>
<Input
id="width"
type="number"
value={formData.default_size.width}
onChange={(e) => handleChange("default_size.width", parseInt(e.target.value))}
min="1"
/>
</div>
<div>
<Label htmlFor="height"> (px)</Label>
<Input
id="height"
type="number"
value={formData.default_size.height}
onChange={(e) => handleChange("default_size.height", parseInt(e.target.value))}
min="1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleChange("sort_order", parseInt(e.target.value))}
min="0"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleChange("is_active", checked ? "Y" : "N")}
/>
<Label htmlFor="active"></Label>
</div>
</div>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleReset} disabled={isSubmitting}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (mode === "create" && duplicateCheck.data?.isDuplicate)}
>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "저장 중..." : mode === "create" ? "생성" : "수정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};