505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Plus, Pencil, Trash2, Search, RefreshCw } from "lucide-react";
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
import {
|
|
SystemNotice,
|
|
CreateSystemNoticePayload,
|
|
getSystemNotices,
|
|
createSystemNotice,
|
|
updateSystemNotice,
|
|
deleteSystemNotice,
|
|
} from "@/lib/api/systemNotice";
|
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
|
|
|
function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } {
|
|
if (priority >= 3) return { label: "높음", variant: "destructive" };
|
|
if (priority === 2) return { label: "보통", variant: "default" };
|
|
return { label: "낮음", variant: "secondary" };
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
if (!dateStr) return "-";
|
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
}
|
|
|
|
const EMPTY_FORM: CreateSystemNoticePayload = {
|
|
title: "",
|
|
content: "",
|
|
is_active: true,
|
|
priority: 1,
|
|
};
|
|
|
|
export default function SystemNoticesPage() {
|
|
const [notices, setNotices] = useState<SystemNotice[]>([]);
|
|
const [filteredNotices, setFilteredNotices] = useState<SystemNotice[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
const [searchText, setSearchText] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [editTarget, setEditTarget] = useState<SystemNotice | null>(null);
|
|
const [formData, setFormData] = useState<CreateSystemNoticePayload>(EMPTY_FORM);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<SystemNotice | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const loadNotices = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setErrorMsg(null);
|
|
const result = await getSystemNotices();
|
|
if (result.success && result.data) {
|
|
setNotices(result.data);
|
|
} else {
|
|
setErrorMsg(result.message || "공지사항 목록을 불러오는 데 실패했습니다.");
|
|
}
|
|
setIsLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadNotices();
|
|
}, [loadNotices]);
|
|
|
|
useEffect(() => {
|
|
let result = [...notices];
|
|
|
|
if (statusFilter !== "all") {
|
|
const isActive = statusFilter === "active";
|
|
result = result.filter((n) => n.is_active === isActive);
|
|
}
|
|
|
|
if (searchText.trim()) {
|
|
const keyword = searchText.toLowerCase();
|
|
result = result.filter(
|
|
(n) =>
|
|
n.title.toLowerCase().includes(keyword) ||
|
|
n.content.toLowerCase().includes(keyword)
|
|
);
|
|
}
|
|
|
|
setFilteredNotices(result);
|
|
}, [notices, searchText, statusFilter]);
|
|
|
|
const handleOpenCreate = () => {
|
|
setEditTarget(null);
|
|
setFormData(EMPTY_FORM);
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const handleOpenEdit = (notice: SystemNotice) => {
|
|
setEditTarget(notice);
|
|
setFormData({
|
|
title: notice.title,
|
|
content: notice.content,
|
|
is_active: notice.is_active,
|
|
priority: notice.priority,
|
|
});
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formData.title.trim()) {
|
|
alert("제목을 입력해주세요.");
|
|
return;
|
|
}
|
|
if (!formData.content.trim()) {
|
|
alert("내용을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
let result;
|
|
|
|
if (editTarget) {
|
|
result = await updateSystemNotice(editTarget.id, formData);
|
|
} else {
|
|
result = await createSystemNotice(formData);
|
|
}
|
|
|
|
if (result.success) {
|
|
setIsFormOpen(false);
|
|
await loadNotices();
|
|
} else {
|
|
alert(result.message || "저장에 실패했습니다.");
|
|
}
|
|
setIsSaving(false);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
setIsDeleting(true);
|
|
const result = await deleteSystemNotice(deleteTarget.id);
|
|
if (result.success) {
|
|
setDeleteTarget(null);
|
|
await loadNotices();
|
|
} else {
|
|
alert(result.message || "삭제에 실패했습니다.");
|
|
}
|
|
setIsDeleting(false);
|
|
};
|
|
|
|
const columns: RDVColumn<SystemNotice>[] = [
|
|
{
|
|
key: "title",
|
|
label: "제목",
|
|
render: (_val, notice) => (
|
|
<span className="font-medium">{notice.title}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "is_active",
|
|
label: "상태",
|
|
width: "100px",
|
|
render: (_val, notice) => (
|
|
<Badge variant={notice.is_active ? "default" : "secondary"}>
|
|
{notice.is_active ? "활성" : "비활성"}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: "priority",
|
|
label: "우선순위",
|
|
width: "100px",
|
|
render: (_val, notice) => {
|
|
const p = getPriorityLabel(notice.priority);
|
|
return <Badge variant={p.variant}>{p.label}</Badge>;
|
|
},
|
|
},
|
|
{
|
|
key: "created_by",
|
|
label: "작성자",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (_val, notice) => (
|
|
<span className="text-muted-foreground">{notice.created_by || "-"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "created_at",
|
|
label: "작성일",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (_val, notice) => (
|
|
<span className="text-muted-foreground">{formatDate(notice.created_at)}</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
const cardFields: RDVCardField<SystemNotice>[] = [
|
|
{
|
|
label: "작성자",
|
|
render: (notice) => notice.created_by || "-",
|
|
},
|
|
{
|
|
label: "작성일",
|
|
render: (notice) => formatDate(notice.created_at),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col bg-background">
|
|
<div className="space-y-6 p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="space-y-2 border-b pb-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">시스템 공지사항</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
시스템 사용자에게 전달할 공지사항을 관리합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 에러 메시지 */}
|
|
{errorMsg && (
|
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
|
|
<button
|
|
onClick={() => setErrorMsg(null)}
|
|
className="text-destructive transition-colors hover:text-destructive/80"
|
|
aria-label="에러 메시지 닫기"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<p className="mt-1.5 text-sm text-destructive/80">{errorMsg}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 검색 툴바 */}
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<div className="w-full sm:w-[160px]">
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="h-10">
|
|
<SelectValue placeholder="상태 필터" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="active">활성</SelectItem>
|
|
<SelectItem value="inactive">비활성</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="relative w-full sm:w-[300px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="제목 또는 내용으로 검색..."
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-muted-foreground">
|
|
총 <span className="font-semibold text-foreground">{filteredNotices.length}</span> 건
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-10 w-10"
|
|
onClick={loadNotices}
|
|
aria-label="새로고침"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</Button>
|
|
<Button className="h-10 gap-2 text-sm font-medium" onClick={handleOpenCreate}>
|
|
<Plus className="h-4 w-4" />
|
|
등록
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ResponsiveDataView<SystemNotice>
|
|
data={filteredNotices}
|
|
columns={columns}
|
|
keyExtractor={(n) => String(n.id)}
|
|
isLoading={isLoading}
|
|
emptyMessage="공지사항이 없습니다."
|
|
skeletonCount={5}
|
|
cardTitle={(n) => n.title}
|
|
cardHeaderRight={(n) => (
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => handleOpenEdit(n)}
|
|
aria-label="수정"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
onClick={() => setDeleteTarget(n)}
|
|
aria-label="삭제"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
cardSubtitle={(n) => {
|
|
const p = getPriorityLabel(n.priority);
|
|
return (
|
|
<span className="flex flex-wrap gap-2 pt-1">
|
|
<Badge variant={n.is_active ? "default" : "secondary"}>
|
|
{n.is_active ? "활성" : "비활성"}
|
|
</Badge>
|
|
<Badge variant={p.variant}>{p.label}</Badge>
|
|
</span>
|
|
);
|
|
}}
|
|
cardFields={cardFields}
|
|
actionsLabel="관리"
|
|
actionsWidth="120px"
|
|
renderActions={(notice) => (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => handleOpenEdit(notice)}
|
|
aria-label="수정"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
onClick={() => setDeleteTarget(notice)}
|
|
aria-label="삭제"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 등록/수정 모달 */}
|
|
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[540px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{editTarget ? "공지사항 수정" : "공지사항 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{editTarget ? "공지사항 내용을 수정합니다." : "새로운 공지사항을 등록합니다."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="notice-title" className="text-xs sm:text-sm">
|
|
제목 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="notice-title"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
|
placeholder="공지사항 제목을 입력하세요"
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="notice-content" className="text-xs sm:text-sm">
|
|
내용 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="notice-content"
|
|
value={formData.content}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, content: e.target.value }))}
|
|
placeholder="공지사항 내용을 입력하세요"
|
|
className="mt-1 min-h-[120px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="notice-priority" className="text-xs sm:text-sm">
|
|
우선순위
|
|
</Label>
|
|
<Select
|
|
value={String(formData.priority)}
|
|
onValueChange={(val) =>
|
|
setFormData((prev) => ({ ...prev, priority: Number(val) }))
|
|
}
|
|
>
|
|
<SelectTrigger id="notice-priority" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="우선순위 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1">낮음</SelectItem>
|
|
<SelectItem value="2">보통</SelectItem>
|
|
<SelectItem value="3">높음</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="notice-active"
|
|
checked={formData.is_active}
|
|
onCheckedChange={(checked) =>
|
|
setFormData((prev) => ({ ...prev, is_active: !!checked }))
|
|
}
|
|
/>
|
|
<Label htmlFor="notice-active" className="cursor-pointer text-xs sm:text-sm">
|
|
활성화 (체크 시 공지사항이 사용자에게 표시됩니다)
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsFormOpen(false)}
|
|
disabled={isSaving}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isSaving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[440px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">공지사항 삭제</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
아래 공지사항을 삭제하시겠습니까?
|
|
<br />이 작업은 되돌릴 수 없습니다.
|
|
<br />
|
|
<span className="mt-2 block font-medium text-foreground">
|
|
"{deleteTarget?.title}"
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteTarget(null)}
|
|
disabled={isDeleting}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isDeleting ? "삭제 중..." : "삭제"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|