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

535 lines
19 KiB
TypeScript
Raw Normal View History

2025-09-10 18:36:28 +09:00
"use client";
import React, { useState } 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,
2025-11-05 16:36:32 +09:00
2025-09-10 18:36:28 +09:00
DialogHeader,
2025-11-05 16:36:32 +09:00
} from "@/components/ui/resizable-dialog";
2025-09-10 18:36:28 +09:00
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Grid,
Layout,
Navigation,
Building,
FileText,
Table,
LayoutDashboard,
Plus,
Minus,
Info,
Wand2,
} from "lucide-react";
import { LayoutCategory } from "@/types/layout";
import { toast } from "sonner";
interface LayoutFormModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
// 카테고리 정의
const CATEGORIES = [
{ id: "basic", name: "기본", icon: Grid, description: "그리드, 플렉스박스 등 기본 레이아웃" },
{ id: "navigation", name: "네비게이션", icon: Navigation, description: "메뉴, 탭, 아코디언 등" },
{ id: "business", name: "비즈니스", icon: Building, description: "대시보드, 차트, 리포트 등" },
{ id: "form", name: "폼", icon: FileText, description: "입력 폼, 설정 패널 등" },
{ id: "table", name: "테이블", icon: Table, description: "데이터 테이블, 목록 등" },
{ id: "dashboard", name: "대시보드", icon: LayoutDashboard, description: "위젯, 카드 레이아웃 등" },
] as const;
// 레이아웃 템플릿 정의
const LAYOUT_TEMPLATES = [
{
id: "2-column",
name: "2열 레이아웃",
description: "좌우 2개 영역으로 구성",
zones: 2,
example: "사이드바 + 메인 콘텐츠",
icon: "▢ ▢",
},
{
id: "3-column",
name: "3열 레이아웃",
description: "좌측, 중앙, 우측 3개 영역",
zones: 3,
example: "네비 + 콘텐츠 + 사이드",
icon: "▢ ▢ ▢",
},
{
id: "header-content",
name: "헤더-콘텐츠",
description: "상단 헤더 + 하단 콘텐츠",
zones: 2,
example: "제목 + 내용 영역",
icon: "▬\n▢",
},
{
id: "card-grid",
name: "카드 그리드",
description: "2x2 카드 격자 구조",
zones: 4,
example: "대시보드, 통계 패널",
icon: "▢▢\n▢▢",
},
{
id: "accordion",
name: "아코디언",
description: "접고 펼칠 수 있는 섹션들",
zones: 3,
example: "FAQ, 설정 패널",
icon: "▷ ▽ ▷",
},
{
id: "tabs",
name: "탭 레이아웃",
description: "탭으로 구성된 다중 패널",
zones: 3,
example: "설정, 상세 정보",
icon: "[Tab1][Tab2][Tab3]",
},
];
export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenChange, onSuccess }) => {
const [step, setStep] = useState<"basic" | "template" | "advanced">("basic");
const [formData, setFormData] = useState({
name: "",
nameEng: "",
description: "",
category: "" as LayoutCategory | "",
zones: 2,
template: "",
author: "Developer",
});
const [isGenerating, setIsGenerating] = useState(false);
const [generationResult, setGenerationResult] = useState<{
success: boolean;
message: string;
files?: string[];
} | null>(null);
const handleReset = () => {
setStep("basic");
setFormData({
name: "",
nameEng: "",
description: "",
category: "",
zones: 2,
template: "",
author: "Developer",
});
setGenerationResult(null);
};
const handleClose = () => {
handleReset();
onOpenChange(false);
};
const handleNext = () => {
if (step === "basic") {
setStep("template");
} else if (step === "template") {
setStep("advanced");
}
};
const handleBack = () => {
if (step === "template") {
setStep("basic");
} else if (step === "advanced") {
setStep("template");
}
};
const validateBasic = () => {
return formData.name.trim() && formData.category && formData.description.trim();
};
const validateTemplate = () => {
return formData.template && formData.zones > 0;
};
const generateLayout = async () => {
try {
setIsGenerating(true);
// CLI 명령어 구성
const command = [
formData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
`--category=${formData.category}`,
`--zones=${formData.zones}`,
`--description="${formData.description}"`,
formData.author !== "Developer" ? `--author="${formData.author}"` : null,
]
.filter(Boolean)
.join(" ");
// API 호출로 CLI 명령어 실행
const response = await fetch("/api/admin/layouts/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
command,
layoutData: formData,
}),
});
const result = await response.json();
if (result.success) {
setGenerationResult({
success: true,
message: "레이아웃이 성공적으로 생성되었습니다!",
files: result.files || [],
});
toast.success("레이아웃 생성 완료");
// 3초 후 자동으로 모달 닫고 새로고침
setTimeout(() => {
handleClose();
onSuccess();
}, 3000);
} else {
setGenerationResult({
success: false,
message: result.message || "레이아웃 생성에 실패했습니다.",
});
toast.error("레이아웃 생성 실패");
}
} catch (error) {
console.error("레이아웃 생성 오류:", error);
setGenerationResult({
success: false,
message: "서버 오류가 발생했습니다.",
});
toast.error("서버 오류");
} finally {
setIsGenerating(false);
}
};
return (
2025-11-05 16:36:32 +09:00
<ResizableDialog open={open} onOpenChange={onOpenChange}>
<ResizableDialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
2025-09-10 18:36:28 +09:00
<Wand2 className="h-5 w-5" />
2025-11-05 16:36:32 +09:00
</ResizableDialogTitle>
<ResizableDialogDescription>GUI를 .</ResizableDialogDescription>
</ResizableDialogHeader>
2025-09-10 18:36:28 +09:00
{/* 단계 표시기 */}
<div className="mb-6 flex items-center justify-center">
<div className="flex items-center gap-4">
<div
className={`flex items-center gap-2 ${step === "basic" ? "text-primary" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
2025-09-10 18:36:28 +09:00
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-primary/20 text-primary" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
2025-09-10 18:36:28 +09:00
>
1
</div>
<span className="text-sm font-medium"> </span>
</div>
<div className="h-px w-8 bg-gray-300" />
<div
className={`flex items-center gap-2 ${step === "template" ? "text-primary" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
2025-09-10 18:36:28 +09:00
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-primary/20 text-primary" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
2025-09-10 18:36:28 +09:00
>
2
</div>
<span className="text-sm font-medium">릿 </span>
</div>
<div className="h-px w-8 bg-gray-300" />
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-primary" : "text-gray-400"}`}>
2025-09-10 18:36:28 +09:00
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-primary/20 text-primary" : "bg-gray-100"}`}
2025-09-10 18:36:28 +09:00
>
3
</div>
<span className="text-sm font-medium"> </span>
</div>
</div>
</div>
{/* 단계별 컨텐츠 */}
<div className="space-y-6">
{step === "basic" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
placeholder="예: 사이드바, 대시보드, 카드그리드"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEng"> </Label>
<Input
id="nameEng"
placeholder="예: Sidebar, Dashboard, CardGrid"
value={formData.nameEng}
onChange={(e) => setFormData((prev) => ({ ...prev, nameEng: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label> *</Label>
<div className="grid grid-cols-2 gap-3">
{CATEGORIES.map((category) => {
const IconComponent = category.icon;
return (
<Card
key={category.id}
className={`cursor-pointer transition-all ${
formData.category === category.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
2025-09-10 18:36:28 +09:00
}`}
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<IconComponent className="h-5 w-5 text-muted-foreground" />
2025-09-10 18:36:28 +09:00
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
placeholder="레이아웃의 용도와 특징을 설명해주세요..."
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
</div>
)}
{step === "template" && (
<div className="space-y-4">
<div>
<Label> 릿 *</Label>
<p className="mb-3 text-sm text-gray-500"> </p>
<div className="grid grid-cols-2 gap-3">
{LAYOUT_TEMPLATES.map((template) => (
<Card
key={template.id}
className={`cursor-pointer transition-all ${
formData.template === template.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
2025-09-10 18:36:28 +09:00
}`}
onClick={() =>
setFormData((prev) => ({
...prev,
template: template.id,
zones: template.zones,
}))
}
>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="font-medium">{template.name}</div>
<Badge variant="secondary">{template.zones} </Badge>
</div>
<div className="text-sm text-muted-foreground">{template.description}</div>
2025-09-10 18:36:28 +09:00
<div className="text-xs text-gray-500">: {template.example}</div>
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="zones"> </Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFormData((prev) => ({
...prev,
zones: Math.max(1, prev.zones - 1),
}))
}
disabled={formData.zones <= 1}
>
<Minus className="h-4 w-4" />
</Button>
<Input
id="zones"
type="number"
min="1"
max="10"
value={formData.zones}
onChange={(e) =>
setFormData((prev) => ({
...prev,
zones: parseInt(e.target.value) || 1,
}))
}
className="w-20 text-center"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFormData((prev) => ({
...prev,
zones: Math.min(10, prev.zones + 1),
}))
}
disabled={formData.zones >= 10}
>
<Plus className="h-4 w-4" />
</Button>
<span className="text-sm text-gray-500"> </span>
</div>
</div>
</div>
)}
{step === "advanced" && (
<div className="space-y-4">
{generationResult ? (
<Alert
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"}
2025-09-10 18:36:28 +09:00
>
<Info className="h-4 w-4" />
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
{generationResult.message}
{generationResult.success && generationResult.files && (
<div className="mt-2">
<div className="text-sm font-medium"> :</div>
<ul className="mt-1 space-y-1 text-xs">
{generationResult.files.map((file, index) => (
<li key={index} className="text-green-700">
{file}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<>
<div className="space-y-2">
<Label htmlFor="author"></Label>
<Input
id="author"
value={formData.author}
onChange={(e) => setFormData((prev) => ({ ...prev, author: e.target.value }))}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<strong>:</strong> {formData.name || "이름 없음"}
</div>
<div>
<strong>:</strong>{" "}
{CATEGORIES.find((c) => c.id === formData.category)?.name || "선택 안됨"}
</div>
<div>
<strong>릿:</strong>{" "}
{LAYOUT_TEMPLATES.find((t) => t.id === formData.template)?.name || "선택 안됨"}
</div>
<div>
<strong> :</strong> {formData.zones}
</div>
<div>
<strong> :</strong>
</div>
<ul className="ml-4 space-y-1 text-xs text-muted-foreground">
2025-09-10 18:36:28 +09:00
<li> {formData.name.toLowerCase()}/index.ts</li>
<li>
{formData.name.toLowerCase()}/{formData.name}Layout.tsx
</li>
<li>
{formData.name.toLowerCase()}/{formData.name}LayoutRenderer.tsx
</li>
<li> {formData.name.toLowerCase()}/config.ts</li>
<li> {formData.name.toLowerCase()}/types.ts</li>
<li> {formData.name.toLowerCase()}/README.md</li>
</ul>
</CardContent>
</Card>
</>
)}
</div>
)}
</div>
<DialogFooter className="gap-2">
{step !== "basic" && !generationResult && (
<Button variant="outline" onClick={handleBack}>
</Button>
)}
{step === "basic" && (
<Button onClick={handleNext} disabled={!validateBasic()}>
</Button>
)}
{step === "template" && (
<Button onClick={handleNext} disabled={!validateTemplate()}>
</Button>
)}
{step === "advanced" && !generationResult && (
<Button onClick={generateLayout} disabled={isGenerating}>
{isGenerating ? "생성 중..." : "레이아웃 생성"}
</Button>
)}
<Button variant="outline" onClick={handleClose}>
{generationResult?.success ? "완료" : "취소"}
</Button>
2025-11-05 16:36:32 +09:00
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
2025-09-10 18:36:28 +09:00
);
};