Merge branch 'lhj'
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
699
frontend/app/(main)/admin/mail/sent/page.tsx
Normal file
699
frontend/app/(main)/admin/mail/sent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user