- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
"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,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
} from "@/components/ui/dialog";
|
|
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";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
|
|
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 || "레이아웃 생성에 실패했습니다.",
|
|
});
|
|
showErrorToast("레이아웃 생성에 실패했습니다", result.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
} catch (error) {
|
|
console.error("레이아웃 생성 오류:", error);
|
|
setGenerationResult({
|
|
success: false,
|
|
message: "서버 오류가 발생했습니다.",
|
|
});
|
|
showErrorToast("레이아웃 생성에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
|
</DialogTitle>
|
|
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 단계 표시기 */}
|
|
<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"}`}
|
|
>
|
|
<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"}`}
|
|
>
|
|
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"}`}
|
|
>
|
|
<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"}`}
|
|
>
|
|
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"}`}>
|
|
<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"}`}
|
|
>
|
|
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"
|
|
}`}
|
|
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" />
|
|
<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"
|
|
}`}
|
|
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>
|
|
<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"}
|
|
>
|
|
<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">
|
|
<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>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|