Merge branch 'lhj'

This commit is contained in:
leeheejin
2025-10-13 15:19:59 +09:00
57 changed files with 4804 additions and 4106 deletions

View File

@@ -1,513 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
// 기본 변형 목록
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
export default function EditButtonActionPage() {
const params = useParams();
const router = useRouter();
const actionType = params.actionType as string;
const { buttonActions, updateButtonAction, isUpdating, updateError, isLoading } = useButtonActions();
const [formData, setFormData] = useState<Partial<ButtonActionFormData>>({});
const [originalData, setOriginalData] = useState<any>(null);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [jsonErrors, setJsonErrors] = useState<{
validation_rules?: string;
action_config?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
validation_rules: "{}",
action_config: "{}",
});
// 버튼 액션 데이터 로드
useEffect(() => {
if (buttonActions && actionType && !isDataLoaded) {
const found = buttonActions.find((ba) => ba.action_type === actionType);
if (found) {
setOriginalData(found);
setFormData({
action_name: found.action_name,
action_name_eng: found.action_name_eng || "",
description: found.description || "",
category: found.category,
default_text: found.default_text || "",
default_text_eng: found.default_text_eng || "",
default_icon: found.default_icon || "",
default_color: found.default_color || "",
default_variant: found.default_variant || "default",
confirmation_required: found.confirmation_required || false,
confirmation_message: found.confirmation_message || "",
validation_rules: found.validation_rules || {},
action_config: found.action_config || {},
sort_order: found.sort_order || 0,
is_active: found.is_active,
});
setJsonStrings({
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
action_config: JSON.stringify(found.action_config || {}, null, 2),
});
setIsDataLoaded(true);
} else {
toast.error("버튼 액션을 찾을 수 없습니다.");
router.push("/admin/system-settings/button-actions");
}
}
}, [buttonActions, actionType, isDataLoaded, router]);
// 입력값 변경 핸들러
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.action_name?.trim()) {
toast.error("액션명을 입력해주세요.");
return false;
}
if (!formData.category?.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await updateButtonAction(actionType, formData);
toast.success("버튼 액션이 성공적으로 수정되었습니다.");
router.push(`/admin/system-settings/button-actions/${actionType}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
}
};
// 폼 초기화 (원본 데이터로 되돌리기)
const handleReset = () => {
if (originalData) {
setFormData({
action_name: originalData.action_name,
action_name_eng: originalData.action_name_eng || "",
description: originalData.description || "",
category: originalData.category,
default_text: originalData.default_text || "",
default_text_eng: originalData.default_text_eng || "",
default_icon: originalData.default_icon || "",
default_color: originalData.default_color || "",
default_variant: originalData.default_variant || "default",
confirmation_required: originalData.confirmation_required || false,
confirmation_message: originalData.confirmation_message || "",
validation_rules: originalData.validation_rules || {},
action_config: originalData.action_config || {},
sort_order: originalData.sort_order || 0,
is_active: originalData.is_active,
});
setJsonStrings({
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
action_config: JSON.stringify(originalData.action_config || {}, null, 2),
});
setJsonErrors({});
}
};
// 로딩 상태
if (isLoading || !isDataLoaded) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 버튼 액션을 찾지 못한 경우
if (!originalData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<Badge variant="outline" className="font-mono">
{actionType}
</Badge>
</div>
<p className="text-muted-foreground">{originalData.action_name} .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 액션 타입 (읽기 전용) */}
<div className="space-y-2">
<Label htmlFor="action_type"> </Label>
<Input id="action_type" value={actionType} disabled className="bg-muted font-mono" />
<p className="text-muted-foreground text-xs"> .</p>
</div>
{/* 액션명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="action_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_name"
value={formData.action_name || ""}
onChange={(e) => handleInputChange("action_name", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action_name_eng"></Label>
<Input
id="action_name_eng"
value={formData.action_name_eng || ""}
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order || 0}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* 기본 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 기본 텍스트 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_text"> </Label>
<Input
id="default_text"
value={formData.default_text || ""}
onChange={(e) => handleInputChange("default_text", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_text_eng"> </Label>
<Input
id="default_text_eng"
value={formData.default_text_eng || ""}
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 아이콘 및 색상 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_icon"> </Label>
<Input
id="default_icon"
value={formData.default_icon || ""}
onChange={(e) => handleInputChange("default_icon", e.target.value)}
placeholder="예: Save (Lucide 아이콘명)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_color"> </Label>
<Input
id="default_color"
value={formData.default_color || ""}
onChange={(e) => handleInputChange("default_color", e.target.value)}
placeholder="예: blue, red, green..."
/>
</div>
</div>
{/* 변형 */}
<div className="space-y-2">
<Label htmlFor="default_variant"> </Label>
<Select
value={formData.default_variant || "default"}
onValueChange={(value) => handleInputChange("default_variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="변형 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_VARIANTS.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 확인 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="confirmation_required"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="confirmation_required"
checked={formData.confirmation_required || false}
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
/>
</div>
{formData.confirmation_required && (
<div className="space-y-2">
<Label htmlFor="confirmation_message"> </Label>
<Textarea
id="confirmation_message"
value={formData.confirmation_message || ""}
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
placeholder="예: 정말로 삭제하시겠습니까?"
rows={2}
/>
</div>
)}
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"requiresData": true, "minItems": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 액션 설정 */}
<div className="space-y-2">
<Label htmlFor="action_config"> </Label>
<Textarea
id="action_config"
value={jsonStrings.action_config}
onChange={(e) => handleJsonChange("action_config", e.target.value)}
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex items-center justify-between">
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
</Button>
</Link>
<div className="flex gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isUpdating}>
<Save className="mr-2 h-4 w-4" />
{isUpdating ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 에러 메시지 */}
{updateError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,344 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { ArrowLeft, Edit, Settings, Code, Eye, CheckCircle, AlertCircle } from "lucide-react";
import { useButtonActions } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
export default function ButtonActionDetailPage() {
const params = useParams();
const router = useRouter();
const actionType = params.actionType as string;
const { buttonActions, isLoading, error } = useButtonActions();
const [actionData, setActionData] = useState<any>(null);
// 버튼 액션 데이터 로드
useEffect(() => {
if (buttonActions && actionType) {
const found = buttonActions.find((ba) => ba.action_type === actionType);
if (found) {
setActionData(found);
} else {
toast.error("버튼 액션을 찾을 수 없습니다.");
router.push("/admin/system-settings/button-actions");
}
}
}, [buttonActions, actionType, router]);
// JSON 포맷팅 함수
const formatJson = (obj: any): string => {
if (!obj || typeof obj !== "object") return "{}";
try {
return JSON.stringify(obj, null, 2);
} catch {
return "{}";
}
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
// 버튼 액션을 찾지 못한 경우
if (!actionData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/system-settings/button-actions">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{actionData.action_name}</h1>
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
{actionData.confirmation_required && (
<Badge variant="outline" className="text-orange-600">
<AlertCircle className="mr-1 h-3 w-3" />
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-4">
<p className="text-muted-foreground font-mono">{actionData.action_type}</p>
{actionData.action_name_eng && <p className="text-muted-foreground">{actionData.action_name_eng}</p>}
</div>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/system-settings/button-actions/${actionType}/edit`}>
<Button>
<Edit className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="config" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="json" className="flex items-center gap-2">
<Code className="h-4 w-4" />
JSON
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono text-lg">{actionData.action_type}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{actionData.action_name}</dd>
</div>
{actionData.action_name_eng && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{actionData.action_name_eng}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant="secondary">{actionData.category}</Badge>
</dd>
</div>
{actionData.description && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-muted-foreground text-sm">{actionData.description}</dd>
</div>
)}
</CardContent>
</Card>
{/* 기본 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{actionData.default_text && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{actionData.default_text}</dd>
{actionData.default_text_eng && (
<dd className="text-muted-foreground text-sm">{actionData.default_text_eng}</dd>
)}
</div>
)}
{actionData.default_icon && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono">{actionData.default_icon}</dd>
</div>
)}
{actionData.default_color && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd>
<Badge
variant="outline"
style={{
borderColor: actionData.default_color,
color: actionData.default_color,
}}
>
{actionData.default_color}
</Badge>
</dd>
</div>
)}
{actionData.default_variant && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd>
<Badge variant="outline">{actionData.default_variant}</Badge>
</dd>
</div>
)}
</CardContent>
</Card>
{/* 확인 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="flex items-center gap-2">
{actionData.confirmation_required ? (
<>
<AlertCircle className="h-4 w-4 text-orange-600" />
<span className="text-orange-600"></span>
</>
) : (
<>
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="text-green-600"></span>
</>
)}
</dd>
</div>
{actionData.confirmation_required && actionData.confirmation_message && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="bg-muted rounded-md p-3 text-sm">{actionData.confirmation_message}</dd>
</div>
)}
</CardContent>
</Card>
{/* 메타데이터 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{actionData.sort_order || 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">
{actionData.created_date ? new Date(actionData.created_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{actionData.created_by || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-sm">
{actionData.updated_date ? new Date(actionData.updated_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{actionData.updated_by || "-"}</dd>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="config" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(actionData.validation_rules)}
</pre>
</CardContent>
</Card>
{/* 액션 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(actionData.action_config)}
</pre>
</CardContent>
</Card>
</div>
</TabsContent>
{/* JSON 데이터 탭 */}
<TabsContent value="json" className="space-y-6">
<Card>
<CardHeader>
<CardTitle> JSON </CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(actionData)}</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,466 +0,0 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
// 기본 변형 목록
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
export default function NewButtonActionPage() {
const router = useRouter();
const { createButtonAction, isCreating, createError } = useButtonActions();
const [formData, setFormData] = useState<ButtonActionFormData>({
action_type: "",
action_name: "",
action_name_eng: "",
description: "",
category: "general",
default_text: "",
default_text_eng: "",
default_icon: "",
default_color: "",
default_variant: "default",
confirmation_required: false,
confirmation_message: "",
validation_rules: {},
action_config: {},
sort_order: 0,
is_active: "Y",
});
const [jsonErrors, setJsonErrors] = useState<{
validation_rules?: string;
action_config?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
validation_rules: "{}",
action_config: "{}",
});
// 입력값 변경 핸들러
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.action_type.trim()) {
toast.error("액션 타입을 입력해주세요.");
return false;
}
if (!formData.action_name.trim()) {
toast.error("액션명을 입력해주세요.");
return false;
}
if (!formData.category.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await createButtonAction(formData);
toast.success("버튼 액션이 성공적으로 생성되었습니다.");
router.push("/admin/system-settings/button-actions");
} catch (error) {
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
}
};
// 폼 초기화
const handleReset = () => {
setFormData({
action_type: "",
action_name: "",
action_name_eng: "",
description: "",
category: "general",
default_text: "",
default_text_eng: "",
default_icon: "",
default_color: "",
default_variant: "default",
confirmation_required: false,
confirmation_message: "",
validation_rules: {},
action_config: {},
sort_order: 0,
is_active: "Y",
});
setJsonStrings({
validation_rules: "{}",
action_config: "{}",
});
setJsonErrors({});
};
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href="/admin/system-settings/button-actions">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 액션 타입 */}
<div className="space-y-2">
<Label htmlFor="action_type">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_type"
value={formData.action_type}
onChange={(e) => handleInputChange("action_type", e.target.value)}
placeholder="예: save, delete, edit..."
className="font-mono"
/>
<p className="text-muted-foreground text-xs"> , , (_) .</p>
</div>
{/* 액션명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="action_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_name"
value={formData.action_name}
onChange={(e) => handleInputChange("action_name", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action_name_eng"></Label>
<Input
id="action_name_eng"
value={formData.action_name_eng}
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* 기본 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 기본 텍스트 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_text"> </Label>
<Input
id="default_text"
value={formData.default_text}
onChange={(e) => handleInputChange("default_text", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_text_eng"> </Label>
<Input
id="default_text_eng"
value={formData.default_text_eng}
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 아이콘 및 색상 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_icon"> </Label>
<Input
id="default_icon"
value={formData.default_icon}
onChange={(e) => handleInputChange("default_icon", e.target.value)}
placeholder="예: Save (Lucide 아이콘명)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_color"> </Label>
<Input
id="default_color"
value={formData.default_color}
onChange={(e) => handleInputChange("default_color", e.target.value)}
placeholder="예: blue, red, green..."
/>
</div>
</div>
{/* 변형 */}
<div className="space-y-2">
<Label htmlFor="default_variant"> </Label>
<Select
value={formData.default_variant}
onValueChange={(value) => handleInputChange("default_variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="변형 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_VARIANTS.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 확인 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="confirmation_required"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="confirmation_required"
checked={formData.confirmation_required}
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
/>
</div>
{formData.confirmation_required && (
<div className="space-y-2">
<Label htmlFor="confirmation_message"> </Label>
<Textarea
id="confirmation_message"
value={formData.confirmation_message}
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
placeholder="예: 정말로 삭제하시겠습니까?"
rows={2}
/>
</div>
)}
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"requiresData": true, "minItems": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 액션 설정 */}
<div className="space-y-2">
<Label htmlFor="action_config"> </Label>
<Textarea
id="action_config"
value={jsonStrings.action_config}
onChange={(e) => handleJsonChange("action_config", e.target.value)}
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isCreating}>
<Save className="mr-2 h-4 w-4" />
{isCreating ? "생성 중..." : "저장"}
</Button>
</div>
{/* 에러 메시지 */}
{createError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,376 +0,0 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import {
Plus,
Search,
Edit,
Trash2,
Eye,
Filter,
RotateCcw,
Settings,
SortAsc,
SortDesc,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useButtonActions } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
export default function ButtonActionsManagePage() {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [activeFilter, setActiveFilter] = useState<string>("Y");
const [sortField, setSortField] = useState<string>("sort_order");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
// 버튼 액션 데이터 조회
const { buttonActions, isLoading, error, deleteButtonAction, isDeleting, deleteError, refetch } = useButtonActions({
active: activeFilter || undefined,
search: searchTerm || undefined,
category: categoryFilter || undefined,
});
// 카테고리 목록 생성
const categories = useMemo(() => {
const uniqueCategories = Array.from(new Set(buttonActions.map((ba) => ba.category).filter(Boolean)));
return uniqueCategories.sort();
}, [buttonActions]);
// 필터링 및 정렬된 데이터
const filteredAndSortedButtonActions = useMemo(() => {
let filtered = [...buttonActions];
// 정렬
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof typeof a];
let bValue: any = b[sortField as keyof typeof b];
// 숫자 필드 처리
if (sortField === "sort_order") {
aValue = aValue || 0;
bValue = bValue || 0;
}
// 문자열 필드 처리
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
}
if (typeof bValue === "string") {
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [buttonActions, sortField, sortDirection]);
// 정렬 변경 핸들러
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 삭제 핸들러
const handleDelete = async (actionType: string, actionName: string) => {
try {
await deleteButtonAction(actionType);
toast.success(`버튼 액션 '${actionName}'이 삭제되었습니다.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
}
};
// 필터 초기화
const resetFilters = () => {
setSearchTerm("");
setCategoryFilter("");
setActiveFilter("Y");
setSortField("sort_order");
setSortDirection("asc");
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Button onClick={() => refetch()} variant="outline">
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Link href="/admin/system-settings/button-actions/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 필터 및 검색 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input
placeholder="액션명, 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성화 상태 필터 */}
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
{/* 초기화 버튼 */}
<Button variant="outline" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 결과 통계 */}
<div className="mb-4">
<p className="text-muted-foreground text-sm">
{filteredAndSortedButtonActions.length} .
</p>
</div>
{/* 버튼 액션 목록 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
<div className="flex items-center gap-2">
{sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_type")}>
<div className="flex items-center gap-2">
{sortField === "action_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_name")}>
<div className="flex items-center gap-2">
{sortField === "action_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
<div className="flex items-center gap-2">
{sortField === "category" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
<div className="flex items-center gap-2">
{sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
<div className="flex items-center gap-2">
{sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedButtonActions.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="py-8 text-center">
.
</TableCell>
</TableRow>
) : (
filteredAndSortedButtonActions.map((action) => (
<TableRow key={action.action_type}>
<TableCell className="font-mono">{action.sort_order || 0}</TableCell>
<TableCell className="font-mono">{action.action_type}</TableCell>
<TableCell className="font-medium">
{action.action_name}
{action.action_name_eng && (
<div className="text-muted-foreground text-xs">{action.action_name_eng}</div>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{action.category}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">{action.default_text || "-"}</TableCell>
<TableCell>
{action.confirmation_required ? (
<div className="flex items-center gap-1 text-orange-600">
<AlertCircle className="h-4 w-4" />
<span className="text-xs"></span>
</div>
) : (
<div className="flex items-center gap-1 text-gray-500">
<CheckCircle className="h-4 w-4" />
<span className="text-xs"></span>
</div>
)}
</TableCell>
<TableCell className="max-w-xs truncate">{action.description || "-"}</TableCell>
<TableCell>
<Badge variant={action.is_active === "Y" ? "default" : "secondary"}>
{action.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{action.updated_date ? new Date(action.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link href={`/admin/system-settings/button-actions/${action.action_type}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/system-settings/button-actions/${action.action_type}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
'{action.action_name}' ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(action.action_type, action.action_name)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{deleteError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,430 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
export default function EditWebTypePage() {
const params = useParams();
const router = useRouter();
const webType = params.webType as string;
const { webTypes, updateWebType, isUpdating, updateError, isLoading } = useWebTypes();
const [formData, setFormData] = useState<Partial<WebTypeFormData>>({});
const [originalData, setOriginalData] = useState<any>(null);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [jsonErrors, setJsonErrors] = useState<{
default_config?: string;
validation_rules?: string;
default_style?: string;
input_properties?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
// 웹타입 데이터 로드
useEffect(() => {
if (webTypes && webType && !isDataLoaded) {
const found = webTypes.find((wt) => wt.web_type === webType);
if (found) {
setOriginalData(found);
setFormData({
type_name: found.type_name,
type_name_eng: found.type_name_eng || "",
description: found.description || "",
category: found.category,
default_config: found.default_config || {},
validation_rules: found.validation_rules || {},
default_style: found.default_style || {},
input_properties: found.input_properties || {},
sort_order: found.sort_order || 0,
is_active: found.is_active,
});
setJsonStrings({
default_config: JSON.stringify(found.default_config || {}, null, 2),
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
default_style: JSON.stringify(found.default_style || {}, null, 2),
input_properties: JSON.stringify(found.input_properties || {}, null, 2),
});
setIsDataLoaded(true);
} else {
toast.error("웹타입을 찾을 수 없습니다.");
router.push("/admin/system-settings/web-types");
}
}
}, [webTypes, webType, isDataLoaded, router]);
// 입력값 변경 핸들러
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
value: string,
) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.type_name?.trim()) {
toast.error("웹타입명을 입력해주세요.");
return false;
}
if (!formData.category?.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await updateWebType(webType, formData);
toast.success("웹타입이 성공적으로 수정되었습니다.");
router.push(`/admin/system-settings/web-types/${webType}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
}
};
// 폼 초기화 (원본 데이터로 되돌리기)
const handleReset = () => {
if (originalData) {
setFormData({
type_name: originalData.type_name,
type_name_eng: originalData.type_name_eng || "",
description: originalData.description || "",
category: originalData.category,
default_config: originalData.default_config || {},
validation_rules: originalData.validation_rules || {},
default_style: originalData.default_style || {},
input_properties: originalData.input_properties || {},
sort_order: originalData.sort_order || 0,
is_active: originalData.is_active,
});
setJsonStrings({
default_config: JSON.stringify(originalData.default_config || {}, null, 2),
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
default_style: JSON.stringify(originalData.default_style || {}, null, 2),
input_properties: JSON.stringify(originalData.input_properties || {}, null, 2),
});
setJsonErrors({});
}
};
// 로딩 상태
if (isLoading || !isDataLoaded) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 웹타입을 찾지 못한 경우
if (!originalData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href={`/admin/system-settings/web-types/${webType}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<Badge variant="outline" className="font-mono">
{webType}
</Badge>
</div>
<p className="text-muted-foreground">{originalData.type_name} .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 웹타입 코드 (읽기 전용) */}
<div className="space-y-2">
<Label htmlFor="web_type"> </Label>
<Input id="web_type" value={webType} disabled className="bg-muted font-mono" />
<p className="text-muted-foreground text-xs"> .</p>
</div>
{/* 웹타입명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="type_name"
value={formData.type_name || ""}
onChange={(e) => handleInputChange("type_name", e.target.value)}
placeholder="예: 텍스트 입력"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type_name_eng"></Label>
<Input
id="type_name_eng"
value={formData.type_name_eng || ""}
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
placeholder="예: Text Input"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="웹타입에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order || 0}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<div className="space-y-2">
<Label htmlFor="default_config"> </Label>
<Textarea
id="default_config"
value={jsonStrings.default_config}
onChange={(e) => handleJsonChange("default_config", e.target.value)}
placeholder='{"placeholder": "입력하세요..."}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
</div>
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"required": true, "minLength": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 기본 스타일 */}
<div className="space-y-2">
<Label htmlFor="default_style"> </Label>
<Textarea
id="default_style"
value={jsonStrings.default_style}
onChange={(e) => handleJsonChange("default_style", e.target.value)}
placeholder='{"width": "100%", "height": "40px"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
</div>
{/* 입력 속성 */}
<div className="space-y-2">
<Label htmlFor="input_properties">HTML </Label>
<Textarea
id="input_properties"
value={jsonStrings.input_properties}
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
placeholder='{"type": "text", "autoComplete": "off"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex items-center justify-between">
<Link href={`/admin/system-settings/web-types/${webType}`}>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
</Button>
</Link>
<div className="flex gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isUpdating}>
<Save className="mr-2 h-4 w-4" />
{isUpdating ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 에러 메시지 */}
{updateError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,285 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { ArrowLeft, Edit, Settings, Code, Eye } from "lucide-react";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
export default function WebTypeDetailPage() {
const params = useParams();
const router = useRouter();
const webType = params.webType as string;
const { webTypes, isLoading, error } = useWebTypes();
const [webTypeData, setWebTypeData] = useState<any>(null);
// 웹타입 데이터 로드
useEffect(() => {
if (webTypes && webType) {
const found = webTypes.find((wt) => wt.web_type === webType);
if (found) {
setWebTypeData(found);
} else {
toast.error("웹타입을 찾을 수 없습니다.");
router.push("/admin/system-settings/web-types");
}
}
}, [webTypes, webType, router]);
// JSON 포맷팅 함수
const formatJson = (obj: any): string => {
if (!obj || typeof obj !== "object") return "{}";
try {
return JSON.stringify(obj, null, 2);
} catch {
return "{}";
}
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
// 웹타입을 찾지 못한 경우
if (!webTypeData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/system-settings/web-types">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{webTypeData.type_name}</h1>
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
<div className="mt-1 flex items-center gap-4">
<p className="text-muted-foreground font-mono">{webTypeData.web_type}</p>
{webTypeData.type_name_eng && <p className="text-muted-foreground">{webTypeData.type_name_eng}</p>}
</div>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/system-settings/web-types/${webType}/edit`}>
<Button>
<Edit className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="config" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="json" className="flex items-center gap-2">
<Code className="h-4 w-4" />
JSON
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono text-lg">{webTypeData.web_type}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{webTypeData.type_name}</dd>
</div>
{webTypeData.type_name_eng && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{webTypeData.type_name_eng}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant="secondary">{webTypeData.category}</Badge>
</dd>
</div>
{webTypeData.description && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-muted-foreground text-sm">{webTypeData.description}</dd>
</div>
)}
</CardContent>
</Card>
{/* 메타데이터 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{webTypeData.sort_order || 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">
{webTypeData.created_date ? new Date(webTypeData.created_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{webTypeData.created_by || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-sm">
{webTypeData.updated_date ? new Date(webTypeData.updated_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{webTypeData.updated_by || "-"}</dd>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="config" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.default_config)}
</pre>
</CardContent>
</Card>
{/* 유효성 검사 규칙 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.validation_rules)}
</pre>
</CardContent>
</Card>
{/* 기본 스타일 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.default_style)}
</pre>
</CardContent>
</Card>
{/* HTML 입력 속성 */}
<Card>
<CardHeader>
<CardTitle>HTML </CardTitle>
<CardDescription>HTML .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.input_properties)}
</pre>
</CardContent>
</Card>
</div>
</TabsContent>
{/* JSON 데이터 탭 */}
<TabsContent value="json" className="space-y-6">
<Card>
<CardHeader>
<CardTitle> JSON </CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(webTypeData)}</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,381 +0,0 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
export default function NewWebTypePage() {
const router = useRouter();
const { createWebType, isCreating, createError } = useWebTypes();
const [formData, setFormData] = useState<WebTypeFormData>({
web_type: "",
type_name: "",
type_name_eng: "",
description: "",
category: "input",
default_config: {},
validation_rules: {},
default_style: {},
input_properties: {},
sort_order: 0,
is_active: "Y",
});
const [jsonErrors, setJsonErrors] = useState<{
default_config?: string;
validation_rules?: string;
default_style?: string;
input_properties?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
// 입력값 변경 핸들러
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
value: string,
) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.web_type.trim()) {
toast.error("웹타입 코드를 입력해주세요.");
return false;
}
if (!formData.type_name.trim()) {
toast.error("웹타입명을 입력해주세요.");
return false;
}
if (!formData.category.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await createWebType(formData);
toast.success("웹타입이 성공적으로 생성되었습니다.");
router.push("/admin/system-settings/web-types");
} catch (error) {
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
}
};
// 폼 초기화
const handleReset = () => {
setFormData({
web_type: "",
type_name: "",
type_name_eng: "",
description: "",
category: "input",
default_config: {},
validation_rules: {},
default_style: {},
input_properties: {},
sort_order: 0,
is_active: "Y",
});
setJsonStrings({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
setJsonErrors({});
};
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href="/admin/system-settings/web-types">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 웹타입 코드 */}
<div className="space-y-2">
<Label htmlFor="web_type">
<span className="text-red-500">*</span>
</Label>
<Input
id="web_type"
value={formData.web_type}
onChange={(e) => handleInputChange("web_type", e.target.value)}
placeholder="예: text, number, email..."
className="font-mono"
/>
<p className="text-muted-foreground text-xs"> , , (_) .</p>
</div>
{/* 웹타입명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="type_name"
value={formData.type_name}
onChange={(e) => handleInputChange("type_name", e.target.value)}
placeholder="예: 텍스트 입력"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type_name_eng"></Label>
<Input
id="type_name_eng"
value={formData.type_name_eng}
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
placeholder="예: Text Input"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="웹타입에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<div className="space-y-2">
<Label htmlFor="default_config"> </Label>
<Textarea
id="default_config"
value={jsonStrings.default_config}
onChange={(e) => handleJsonChange("default_config", e.target.value)}
placeholder='{"placeholder": "입력하세요..."}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
</div>
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"required": true, "minLength": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 기본 스타일 */}
<div className="space-y-2">
<Label htmlFor="default_style"> </Label>
<Textarea
id="default_style"
value={jsonStrings.default_style}
onChange={(e) => handleJsonChange("default_style", e.target.value)}
placeholder='{"width": "100%", "height": "40px"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
</div>
{/* 입력 속성 */}
<div className="space-y-2">
<Label htmlFor="input_properties">HTML </Label>
<Textarea
id="input_properties"
value={jsonStrings.input_properties}
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
placeholder='{"type": "text", "autoComplete": "off"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isCreating}>
<Save className="mr-2 h-4 w-4" />
{isCreating ? "생성 중..." : "저장"}
</Button>
</div>
{/* 에러 메시지 */}
{createError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,345 +0,0 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
export default function WebTypesManagePage() {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [activeFilter, setActiveFilter] = useState<string>("Y");
const [sortField, setSortField] = useState<string>("sort_order");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
// 웹타입 데이터 조회
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
active: activeFilter || undefined,
search: searchTerm || undefined,
category: categoryFilter || undefined,
});
// 카테고리 목록 생성
const categories = useMemo(() => {
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
return uniqueCategories.sort();
}, [webTypes]);
// 필터링 및 정렬된 데이터
const filteredAndSortedWebTypes = useMemo(() => {
let filtered = [...webTypes];
// 정렬
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof typeof a];
let bValue: any = b[sortField as keyof typeof b];
// 숫자 필드 처리
if (sortField === "sort_order") {
aValue = aValue || 0;
bValue = bValue || 0;
}
// 문자열 필드 처리
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
}
if (typeof bValue === "string") {
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [webTypes, sortField, sortDirection]);
// 정렬 변경 핸들러
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 삭제 핸들러
const handleDelete = async (webType: string, typeName: string) => {
try {
await deleteWebType(webType);
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
}
};
// 필터 초기화
const resetFilters = () => {
setSearchTerm("");
setCategoryFilter("");
setActiveFilter("Y");
setSortField("sort_order");
setSortDirection("asc");
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Button onClick={() => refetch()} variant="outline">
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Link href="/admin/system-settings/web-types/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 필터 및 검색 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input
placeholder="웹타입명, 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성화 상태 필터 */}
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
{/* 초기화 버튼 */}
<Button variant="outline" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 결과 통계 */}
<div className="mb-4">
<p className="text-muted-foreground text-sm"> {filteredAndSortedWebTypes.length} .</p>
</div>
{/* 웹타입 목록 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
<div className="flex items-center gap-2">
{sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
<div className="flex items-center gap-2">
{sortField === "web_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
<div className="flex items-center gap-2">
{sortField === "type_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
<div className="flex items-center gap-2">
{sortField === "category" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead></TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
<div className="flex items-center gap-2">
{sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
<div className="flex items-center gap-2">
{sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedWebTypes.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center">
.
</TableCell>
</TableRow>
) : (
filteredAndSortedWebTypes.map((webType) => (
<TableRow key={webType.web_type}>
<TableCell className="font-mono">{webType.sort_order || 0}</TableCell>
<TableCell className="font-mono">{webType.web_type}</TableCell>
<TableCell className="font-medium">
{webType.type_name}
{webType.type_name_eng && (
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{webType.category}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">{webType.description || "-"}</TableCell>
<TableCell>
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
{webType.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link href={`/admin/system-settings/web-types/${webType.web_type}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/system-settings/web-types/${webType.web_type}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
'{webType.type_name}' ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(webType.web_type, webType.type_name)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{deleteError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@@ -3,7 +3,10 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react";
import { Mail, Plus, Loader2, RefreshCw, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
MailAccount,
getMailAccounts,
@@ -19,6 +22,7 @@ import MailAccountTable from "@/components/mail/MailAccountTable";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailAccountsPage() {
const router = useRouter();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -124,43 +128,60 @@ export default function MailAccountsPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600">SMTP </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadAccounts}
disabled={loading}
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button
className="bg-orange-500 hover:bg-orange-600"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">SMTP </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadAccounts}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="default"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 메인 컨텐츠 */}
{loading ? (
<Card className="shadow-sm">
<Card>
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<Card>
<CardContent className="p-6">
<MailAccountTable
accounts={accounts}
@@ -174,28 +195,28 @@ export default function MailAccountsPage() {
)}
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Mail className="w-5 h-5 mr-2 text-orange-500" />
<Mail className="w-5 h-5 mr-2 text-foreground" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
<p className="text-foreground mb-4">
💡 SMTP !
</p>
<ul className="space-y-2 text-sm text-gray-600">
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span>Gmail, Naver, SMTP </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span> </span>
</li>
</ul>

View File

@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -12,9 +13,9 @@ import {
TrendingUp,
Users,
Calendar,
Clock
ArrowRight
} from "lucide-react";
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail";
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
interface DashboardStats {
totalAccounts: number;
@@ -39,19 +40,26 @@ export default function MailDashboardPage() {
const loadStats = async () => {
setLoading(true);
try {
// 계정 수 (apiClient를 통해 토큰 포함)
const accounts = await getMailAccounts();
// 템플릿 수 (apiClient를 통해 토큰 포함)
const templates = await getMailTemplates();
const mailStats = await getMailStatistics();
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
let receivedTodayCount = 0;
try {
receivedTodayCount = await getTodayReceivedCount();
} catch (error) {
console.error('수신 메일 수 조회 실패:', error);
// 실패 시 0으로 표시
}
setStats({
totalAccounts: accounts.length,
totalTemplates: templates.length,
sentToday: 0, // TODO: 실제 발송 통계 API 연동
receivedToday: 0,
sentThisMonth: 0,
successRate: 0,
sentToday: mailStats.todayCount,
receivedToday: receivedTodayCount,
sentThisMonth: mailStats.thisMonthCount,
successRate: mailStats.successRate,
});
} catch (error) {
// console.error('통계 로드 실패:', error);
@@ -71,7 +79,8 @@ export default function MailDashboardPage() {
icon: Users,
color: "blue",
bgColor: "bg-blue-100",
iconColor: "text-blue-500",
iconColor: "text-blue-600",
href: "/admin/mail/accounts",
},
{
title: "템플릿 수",
@@ -79,7 +88,8 @@ export default function MailDashboardPage() {
icon: FileText,
color: "green",
bgColor: "bg-green-100",
iconColor: "text-green-500",
iconColor: "text-green-600",
href: "/admin/mail/templates",
},
{
title: "오늘 발송",
@@ -87,7 +97,8 @@ export default function MailDashboardPage() {
icon: Send,
color: "orange",
bgColor: "bg-orange-100",
iconColor: "text-orange-500",
iconColor: "text-orange-600",
href: "/admin/mail/sent",
},
{
title: "오늘 수신",
@@ -95,94 +106,171 @@ export default function MailDashboardPage() {
icon: Inbox,
color: "purple",
bgColor: "bg-purple-100",
iconColor: "text-purple-500",
iconColor: "text-purple-600",
href: "/admin/mail/receive",
},
];
const quickLinks = [
{
title: "계정 관리",
description: "메일 계정 설정",
href: "/admin/mail/accounts",
icon: Users,
color: "blue",
},
{
title: "템플릿 관리",
description: "템플릿 편집",
href: "/admin/mail/templates",
icon: FileText,
color: "green",
},
{
title: "메일 발송",
description: "메일 보내기",
href: "/admin/mail/send",
icon: Send,
color: "orange",
},
{
title: "보낸메일함",
description: "발송 이력 확인",
href: "/admin/mail/sent",
icon: Mail,
color: "indigo",
},
{
title: "수신함",
description: "받은 메일 확인",
href: "/admin/mail/receive",
icon: Inbox,
color: "purple",
},
];
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
<div className="min-h-screen bg-background">
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
<div className="flex items-center gap-4">
<div className="p-4 bg-primary/10 rounded-lg">
<Mail className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground mb-1"> </h1>
<p className="text-muted-foreground"> </p>
</div>
</div>
<Button
variant="outline"
size="sm"
variant="outline"
size="lg"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{statCards.map((stat, index) => (
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">{stat.title}</p>
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
<Link key={index} href={stat.href}>
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<p className="text-sm font-medium text-muted-foreground mb-3">
{stat.title}
</p>
<p className="text-4xl font-bold text-foreground">
{stat.value}
</p>
</div>
<div className="p-4 bg-muted rounded-lg">
<stat.icon className="w-7 h-7 text-muted-foreground" />
</div>
</div>
<div className={`${stat.bgColor} p-3 rounded-lg`}>
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
{/* 진행 바 */}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000"
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
></div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</Link>
))}
</div>
{/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle className="flex items-center">
<Calendar className="w-5 h-5 mr-2 text-orange-500" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Card>
<CardHeader className="border-b">
<CardTitle className="text-lg flex items-center">
<div className="p-2 bg-muted rounded-lg mr-3">
<Calendar className="w-5 h-5 text-foreground" />
</div>
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<span className="text-lg font-semibold text-gray-900">
{stats.sentThisMonth}
</span>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} </span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-semibold text-green-600">
{stats.successRate}%
</span>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<span className="text-sm font-medium text-muted-foreground"></span>
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
</div>
<div className="pt-4 border-t">
<div className="flex items-center text-sm text-gray-500">
<TrendingUp className="w-4 h-4 mr-2 text-green-500" />
12%
{/* 전월 대비 통계는 현재 불필요하여 주석처리
<div className="flex items-center justify-between pt-3 border-t">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<TrendingUp className="w-4 h-4" />
전월 대비
</div>
<span className="text-lg font-bold text-foreground">+12%</span>
</div>
*/}
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle className="flex items-center">
<Clock className="w-5 h-5 mr-2 text-blue-500" />
<Card>
<CardHeader className="border-b">
<CardTitle className="text-lg flex items-center">
<div className="p-2 bg-muted rounded-lg mr-3">
<Mail className="w-5 h-5 text-foreground" />
</div>
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-3">
<div className="text-center text-gray-500 py-8">
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"> </p>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<span className="text-sm font-bold text-foreground"> </span>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} </span>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> 릿</span>
</div>
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} </span>
</div>
</div>
</CardContent>
@@ -190,93 +278,32 @@ export default function MailDashboardPage() {
</div>
{/* 빠른 액세스 */}
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle> </CardTitle>
<Card>
<CardHeader className="border-b">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href="/admin/mail/accounts"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Users className="w-8 h-8 text-blue-500 mr-3" />
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
<a
href="/admin/mail/templates"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<FileText className="w-8 h-8 text-green-500 mr-3" />
<div>
<p className="font-medium text-gray-900">릿 </p>
<p className="text-sm text-gray-500">릿 </p>
</div>
</a>
<a
href="/admin/mail/send"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Send className="w-8 h-8 text-orange-500 mr-3" />
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
<a
href="/admin/mail/receive"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Inbox className="w-8 h-8 text-purple-500 mr-3" />
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{quickLinks.map((link, index) => (
<a
key={index}
href={link.href}
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
>
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
<link.icon className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
<p className="text-sm text-muted-foreground truncate">{link.description}</p>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
</a>
))}
</div>
</CardContent>
</Card>
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Mail className="w-5 h-5 mr-2 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
💡 !
</p>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span>SMTP </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> 릿 </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> SQL </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> </span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -15,7 +15,11 @@ import {
Filter,
SortAsc,
SortDesc,
ChevronRight,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
MailAccount,
ReceivedMail,
@@ -26,6 +30,7 @@ import {
import MailDetailModal from "@/components/mail/MailDetailModal";
export default function MailReceivePage() {
const router = useRouter();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [mails, setMails] = useState<ReceivedMail[]>([]);
@@ -197,17 +202,33 @@ export default function MailReceivePage() {
}, [mails, searchTerm, filterStatus, sortBy]);
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600">
IMAP으로
</p>
</div>
<div className="flex gap-2">
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">
IMAP으로
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
@@ -232,20 +253,21 @@ export default function MailReceivePage() {
)}
</Button>
</div>
</div>
</div>
{/* 계정 선택 */}
<Card className="shadow-sm">
<Card className="">
<CardContent className="p-4">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
<label className="text-sm font-medium text-foreground whitespace-nowrap">
:
</label>
<select
value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value=""> </option>
{accounts.map((account) => (
@@ -278,7 +300,7 @@ export default function MailReceivePage() {
{/* 검색 및 필터 */}
{selectedAccountId && (
<Card className="shadow-sm">
<Card className="">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-3">
{/* 검색 */}
@@ -289,17 +311,17 @@ export default function MailReceivePage() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목, 발신자, 내용으로 검색..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
{/* 필터 */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-500" />
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value="all"></option>
<option value="unread"> </option>
@@ -311,14 +333,14 @@ export default function MailReceivePage() {
{/* 정렬 */}
<div className="flex items-center gap-2">
{sortBy.includes("desc") ? (
<SortDesc className="w-4 h-4 text-gray-500" />
<SortDesc className="w-4 h-4 text-muted-foreground" />
) : (
<SortAsc className="w-4 h-4 text-gray-500" />
<SortAsc className="w-4 h-4 text-muted-foreground" />
)}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value="date-desc"> ()</option>
<option value="date-asc"> ()</option>
@@ -330,7 +352,7 @@ export default function MailReceivePage() {
{/* 검색 결과 카운트 */}
{(searchTerm || filterStatus !== "all") && (
<div className="mt-3 text-sm text-gray-600">
<div className="mt-3 text-sm text-muted-foreground">
{filteredAndSortedMails.length}
{searchTerm && (
<span className="ml-2">
@@ -345,17 +367,17 @@ export default function MailReceivePage() {
{/* 메일 목록 */}
{loading ? (
<Card className="shadow-sm">
<Card className="">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-gray-600"> ...</span>
<span className="ml-3 text-muted-foreground"> ...</span>
</CardContent>
</Card>
) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-white shadow-sm">
<Card className="text-center py-16 bg-card ">
<CardContent className="pt-6">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500 mb-4">
<p className="text-muted-foreground mb-4">
{!selectedAccountId
? "메일 계정을 선택하세요"
: searchTerm || filterStatus !== "all"
@@ -379,7 +401,7 @@ export default function MailReceivePage() {
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<Card className="">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
<CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" />
@@ -392,7 +414,7 @@ export default function MailReceivePage() {
<div
key={mail.id}
onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
!mail.isRead ? "bg-blue-50/30" : ""
}`}
>
@@ -410,8 +432,8 @@ export default function MailReceivePage() {
<span
className={`text-sm ${
mail.isRead
? "text-gray-600"
: "text-gray-900 font-semibold"
? "text-muted-foreground"
: "text-foreground font-semibold"
}`}
>
{mail.from}
@@ -420,19 +442,19 @@ export default function MailReceivePage() {
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
)}
<span className="text-xs text-gray-500">
<span className="text-xs text-muted-foreground">
{formatDate(mail.date)}
</span>
</div>
</div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-gray-700" : "text-gray-900 font-medium"
mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-gray-500 line-clamp-2">
<p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview}
</p>
</div>
@@ -445,7 +467,7 @@ export default function MailReceivePage() {
)}
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 shadow-sm">
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
@@ -453,13 +475,13 @@ export default function MailReceivePage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
<p className="text-foreground mb-4">
:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p className="font-medium text-gray-800 mb-2">📬 </p>
<ul className="space-y-1 text-sm text-gray-600">
<ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>IMAP </span>
@@ -480,7 +502,7 @@ export default function MailReceivePage() {
</div>
<div>
<p className="font-medium text-gray-800 mb-2">📄 </p>
<ul className="space-y-1 text-sm text-gray-600">
<ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>HTML </span>
@@ -501,7 +523,7 @@ export default function MailReceivePage() {
</div>
<div>
<p className="font-medium text-gray-800 mb-2">🔍 </p>
<ul className="space-y-1 text-sm text-gray-600">
<ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> (//)</span>
@@ -522,7 +544,7 @@ export default function MailReceivePage() {
</div>
<div>
<p className="font-medium text-gray-800 mb-2">🔒 </p>
<ul className="space-y-1 text-sm text-gray-600">
<ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>XSS (DOMPurify)</span>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,699 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Inbox,
Search,
Filter,
Eye,
Trash2,
RefreshCw,
CheckCircle2,
XCircle,
Mail,
Calendar,
User,
Paperclip,
Loader2,
X,
File,
ChevronRight,
ChevronDown,
ChevronUp,
Send,
AlertCircle,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
SentMailHistory,
getSentMailList,
deleteSentMail,
getMailAccounts,
MailAccount,
getMailStatistics,
} from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast";
export default function SentMailPage() {
const router = useRouter();
const { toast } = useToast();
const [mails, setMails] = useState<SentMailHistory[]>([]);
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMail, setSelectedMail] = useState<SentMailHistory | null>(null);
const [showFilters, setShowFilters] = useState(false);
// 통계
const [stats, setStats] = useState({
totalSent: 0,
successCount: 0,
failedCount: 0,
todayCount: 0,
});
// 필터 및 페이징
const [searchTerm, setSearchTerm] = useState("");
const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'failed'>('all');
const [filterAccountId, setFilterAccountId] = useState<string>('all');
const [sortBy, setSortBy] = useState<'sentAt' | 'subject'>('sentAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
loadAccounts();
loadStats();
}, []);
useEffect(() => {
loadMails();
}, [page, filterStatus, filterAccountId, sortBy, sortOrder]);
const loadAccounts = async () => {
try {
const data = await getMailAccounts();
setAccounts(data);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "계정 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const loadStats = async () => {
try {
const data = await getMailStatistics();
setStats({
totalSent: data.totalSent,
successCount: data.successCount,
failedCount: data.failedCount,
todayCount: data.todayCount,
});
} catch (error: unknown) {
console.error('통계 로드 실패:', error);
}
};
const loadMails = async () => {
try {
setLoading(true);
const result = await getSentMailList({
page,
limit: 20,
searchTerm: searchTerm || undefined,
status: filterStatus,
accountId: filterAccountId !== 'all' ? filterAccountId : undefined,
sortBy,
sortOrder,
});
setMails(result.items);
setTotalPages(result.totalPages);
setTotal(result.total);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "발송 이력 로드 실패",
description: err.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setPage(1);
loadMails();
};
const handleDelete = async (id: string) => {
if (!confirm("이 발송 이력을 삭제하시겠습니까?")) {
return;
}
try {
await deleteSentMail(id);
toast({
title: "삭제 완료",
description: "발송 이력이 삭제되었습니다.",
});
loadMails();
loadStats();
} catch (error: unknown) {
const err = error as Error;
toast({
title: "삭제 실패",
description: err.message,
variant: "destructive",
});
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
};
if (loading && page === 1 && mails.length === 0) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-6 space-y-6 bg-background min-h-screen">
{/* 헤더 */}
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 및 빠른 액션 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
<Inbox className="w-8 h-8" />
</h1>
<p className="mt-2 text-muted-foreground"> {total} </p>
</div>
<Button onClick={loadMails} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.totalSent}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<Send className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.successCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<CheckCircle2 className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.failedCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<XCircle className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.todayCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<Calendar className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 검색 및 필터 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Search className="w-5 h-5" />
<CardTitle></CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="w-4 h-4 mr-2" />
{showFilters ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 기본 검색 */}
<div className="flex gap-2">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목 또는 받는사람으로 검색..."
className="flex-1"
/>
<Button onClick={handleSearch}>
<Search className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 고급 필터 (접기/펼치기) */}
{showFilters && (
<div className="pt-4 border-t space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 상태 필터 */}
<div>
<Label> </Label>
<Select value={filterStatus} onValueChange={(v: any) => {
setFilterStatus(v);
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"> </SelectItem>
<SelectItem value="failed"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 계정 필터 */}
<div>
<Label> </Label>
<Select value={filterAccountId} onValueChange={(v) => {
setFilterAccountId(v);
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{accounts.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
{acc.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 */}
<div>
<Label></Label>
<Select value={`${sortBy}-${sortOrder}`} onValueChange={(v) => {
const [by, order] = v.split('-');
setSortBy(by as 'sentAt' | 'subject');
setSortOrder(order as 'asc' | 'desc');
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sentAt-desc"></SelectItem>
<SelectItem value="sentAt-asc"></SelectItem>
<SelectItem value="subject-asc"> ()</SelectItem>
<SelectItem value="subject-desc"> ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 필터 초기화 */}
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchTerm("");
setFilterStatus('all');
setFilterAccountId('all');
setSortBy('sentAt');
setSortOrder('desc');
setPage(1);
}}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* 메일 목록 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
({total})
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : mails.length === 0 ? (
<div className="text-center py-12">
<Inbox className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-3">
{mails.map((mail) => (
<div
key={mail.id}
className="p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-4">
{/* 메일 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{/* 상태 배지 */}
{mail.status === 'success' ? (
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
<CheckCircle2 className="w-3 h-3 mr-1" />
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
</Badge>
)}
{/* 첨부파일 */}
{mail.attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="w-3 h-3 mr-1" />
{mail.attachmentCount}
</Badge>
)}
</div>
{/* 제목 */}
<h3 className="font-semibold text-foreground mb-1 truncate">
{mail.subject || "(제목 없음)"}
</h3>
{/* 수신자 및 날짜 */}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{Array.isArray(mail.to) ? mail.to.join(", ") : mail.to}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(mail.sentAt)}
</span>
</div>
{/* 실패 메시지 */}
{mail.status === 'failed' && mail.errorMessage && (
<div className="mt-2 text-sm text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{mail.errorMessage}
</div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedMail(mail)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(mail.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
{/* 페이징 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t">
<p className="text-sm text-muted-foreground">
{page} / {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* 메일 상세 모달 */}
{selectedMail && (
<Dialog open={!!selectedMail} onOpenChange={() => setSelectedMail(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 상태 */}
<div>
{selectedMail.status === 'success' ? (
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
<CheckCircle2 className="w-4 h-4 mr-1" />
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-4 h-4 mr-1" />
</Badge>
)}
{selectedMail.status === 'failed' && selectedMail.errorMessage && (
<p className="mt-2 text-sm text-red-600">{selectedMail.errorMessage}</p>
)}
</div>
<Separator />
{/* 발신 정보 */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground"> </h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1 font-medium">{selectedMail.from}</span>
</div>
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">
{Array.isArray(selectedMail.to) ? selectedMail.to.join(", ") : selectedMail.to}
</span>
</div>
{selectedMail.cc && selectedMail.cc.length > 0 && (
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{selectedMail.cc.join(", ")}</span>
</div>
)}
{selectedMail.bcc && selectedMail.bcc.length > 0 && (
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{selectedMail.bcc.join(", ")}</span>
</div>
)}
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{formatDate(selectedMail.sentAt)}</span>
</div>
</div>
</div>
<Separator />
{/* 메일 내용 */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground"> </h3>
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<p className="font-medium">{selectedMail.subject || "(제목 없음)"}</p>
</div>
{selectedMail.templateUsed && (
<div>
<p className="text-sm text-muted-foreground mb-2"> 릿</p>
<Badge variant="outline">{selectedMail.templateUsed}</Badge>
</div>
)}
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<div
className="p-4 border rounded-lg bg-muted/30 max-h-96 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: selectedMail.htmlBody || selectedMail.textBody || "" }}
/>
</div>
</div>
{/* 첨부파일 */}
{selectedMail.attachments && selectedMail.attachments.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<h3 className="font-semibold text-foreground flex items-center gap-2">
<Paperclip className="w-4 h-4" />
({selectedMail.attachments.length})
</h3>
<div className="space-y-2">
{selectedMail.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<File className="w-5 h-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{att.filename}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(att.size || 0)}</p>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
{/* 수신 결과 (성공/실패 목록) */}
{selectedMail.acceptedRecipients && selectedMail.acceptedRecipients.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold text-foreground text-sm"> </h3>
<div className="flex flex-wrap gap-2">
{selectedMail.acceptedRecipients.map((email, idx) => (
<Badge key={idx} variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
{email}
</Badge>
))}
</div>
</div>
</>
)}
{selectedMail.rejectedRecipients && selectedMail.rejectedRecipients.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold text-foreground text-sm"> </h3>
<div className="flex flex-wrap gap-2">
{selectedMail.rejectedRecipients.map((email, idx) => (
<Badge key={idx} variant="destructive">
{email}
</Badge>
))}
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@@ -3,7 +3,10 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, FileText, Loader2, RefreshCw, Search } from "lucide-react";
import { Plus, FileText, Loader2, RefreshCw, Search, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
MailTemplate,
getMailTemplates,
@@ -19,6 +22,7 @@ import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailTemplatesPage() {
const router = useRouter();
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@@ -128,52 +132,69 @@ export default function MailTemplatesPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> 릿 </h1>
<p className="mt-2 text-gray-600"> 릿 </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadTemplates}
disabled={loading}
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={handleOpenCreateModal}
className="bg-orange-500 hover:bg-orange-600"
>
<Plus className="w-4 h-4 mr-2" />
릿
</Button>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">릿 </span>
</nav>
<Separator />
{/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> 릿 </h1>
<p className="mt-2 text-muted-foreground"> 릿 </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadTemplates}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="default"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
릿
</Button>
</div>
</div>
</div>
{/* 검색 및 필터 */}
<Card className="shadow-sm">
<Card>
<CardContent className="p-4">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="템플릿 이름, 제목으로 검색..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
>
<option value="all"> </option>
{categories.map((cat) => (
@@ -188,24 +209,24 @@ export default function MailTemplatesPage() {
{/* 메인 컨텐츠 */}
{loading ? (
<Card className="shadow-sm">
<Card>
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</CardContent>
</Card>
) : filteredTemplates.length === 0 ? (
<Card className="text-center py-16 bg-white shadow-sm">
<Card className="text-center py-16">
<CardContent className="pt-6">
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500 mb-4">
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">
{templates.length === 0
? '아직 생성된 템플릿이 없습니다'
: '검색 결과가 없습니다'}
</p>
{templates.length === 0 && (
<Button
variant="default"
onClick={handleOpenCreateModal}
className="bg-orange-500 hover:bg-orange-600"
>
<Plus className="w-4 h-4 mr-2" />
릿
@@ -229,28 +250,28 @@ export default function MailTemplatesPage() {
)}
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<FileText className="w-5 h-5 mr-2 text-orange-500" />
<FileText className="w-5 h-5 mr-2 text-foreground" />
릿
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
<p className="text-foreground mb-4">
💡 릿 !
</p>
<ul className="space-y-2 text-sm text-gray-600">
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span>, , , </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span className="text-foreground mr-2"></span>
<span> (: {"{customer_name}"})</span>
</li>
</ul>