Files
vexplor_dev/frontend/app/(main)/admin/system-notices/page.tsx
kjs 540b6290c4 Implement multi-language support in audit log, system notices, collection management, and common code management pages
- Integrated multi-language functionality across the audit log, system notices, collection management, and common code management components, enhancing accessibility for diverse users.
- Updated UI elements to utilize translation keys, ensuring that all text is dynamically translated based on user preferences.
- Improved error handling messages to be localized, providing a better user experience in case of issues.

These changes significantly enhance the usability and internationalization of the management features, making the application more inclusive.
2026-04-01 16:16:40 +09:00

624 lines
22 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";
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
// 다국어 키 목록
const LANG_KEYS = [
"notice.page.title",
"notice.page.description",
"notice.error.title",
"notice.error.closeLabel",
"notice.error.loadFailed",
"notice.error.saveFailed",
"notice.error.deleteFailed",
"notice.filter.statusPlaceholder",
"notice.filter.all",
"notice.filter.active",
"notice.filter.inactive",
"notice.filter.searchPlaceholder",
"notice.list.total",
"notice.list.countSuffix",
"notice.list.refreshLabel",
"notice.list.empty",
"notice.button.create",
"notice.button.editLabel",
"notice.button.deleteLabel",
"notice.column.title",
"notice.column.status",
"notice.column.priority",
"notice.column.author",
"notice.column.createdAt",
"notice.column.actions",
"notice.card.author",
"notice.card.createdAt",
"notice.status.active",
"notice.status.inactive",
"notice.priority.high",
"notice.priority.medium",
"notice.priority.low",
"notice.form.titleCreate",
"notice.form.titleEdit",
"notice.form.descriptionCreate",
"notice.form.descriptionEdit",
"notice.form.titleLabel",
"notice.form.titlePlaceholder",
"notice.form.contentLabel",
"notice.form.contentPlaceholder",
"notice.form.priorityLabel",
"notice.form.priorityPlaceholder",
"notice.form.activeLabel",
"notice.form.cancel",
"notice.form.save",
"notice.form.saving",
"notice.validate.titleRequired",
"notice.validate.contentRequired",
"notice.delete.title",
"notice.delete.description",
"notice.delete.descriptionIrreversible",
"notice.delete.cancel",
"notice.delete.confirm",
"notice.delete.deleting",
] as const;
// 한국어 기본 텍스트
const DEFAULT_TEXTS: Record<string, string> = {
"notice.page.title": "시스템 공지사항",
"notice.page.description": "시스템 사용자에게 전달할 공지사항을 관리합니다.",
"notice.error.title": "오류가 발생했습니다",
"notice.error.closeLabel": "에러 메시지 닫기",
"notice.error.loadFailed": "공지사항 목록을 불러오는 데 실패했습니다.",
"notice.error.saveFailed": "저장에 실패했습니다.",
"notice.error.deleteFailed": "삭제에 실패했습니다.",
"notice.filter.statusPlaceholder": "상태 필터",
"notice.filter.all": "전체",
"notice.filter.active": "활성",
"notice.filter.inactive": "비활성",
"notice.filter.searchPlaceholder": "제목 또는 내용으로 검색...",
"notice.list.total": "총",
"notice.list.countSuffix": "건",
"notice.list.refreshLabel": "새로고침",
"notice.list.empty": "공지사항이 없습니다.",
"notice.button.create": "등록",
"notice.button.editLabel": "수정",
"notice.button.deleteLabel": "삭제",
"notice.column.title": "제목",
"notice.column.status": "상태",
"notice.column.priority": "우선순위",
"notice.column.author": "작성자",
"notice.column.createdAt": "작성일",
"notice.column.actions": "관리",
"notice.card.author": "작성자",
"notice.card.createdAt": "작성일",
"notice.status.active": "활성",
"notice.status.inactive": "비활성",
"notice.priority.high": "높음",
"notice.priority.medium": "보통",
"notice.priority.low": "낮음",
"notice.form.titleCreate": "공지사항 등록",
"notice.form.titleEdit": "공지사항 수정",
"notice.form.descriptionCreate": "새로운 공지사항을 등록합니다.",
"notice.form.descriptionEdit": "공지사항 내용을 수정합니다.",
"notice.form.titleLabel": "제목",
"notice.form.titlePlaceholder": "공지사항 제목을 입력하세요",
"notice.form.contentLabel": "내용",
"notice.form.contentPlaceholder": "공지사항 내용을 입력하세요",
"notice.form.priorityLabel": "우선순위",
"notice.form.priorityPlaceholder": "우선순위 선택",
"notice.form.activeLabel": "활성화 (체크 시 공지사항이 사용자에게 표시됩니다)",
"notice.form.cancel": "취소",
"notice.form.save": "저장",
"notice.form.saving": "저장 중...",
"notice.validate.titleRequired": "제목을 입력해주세요.",
"notice.validate.contentRequired": "내용을 입력해주세요.",
"notice.delete.title": "공지사항 삭제",
"notice.delete.description": "아래 공지사항을 삭제하시겠습니까?",
"notice.delete.descriptionIrreversible": "이 작업은 되돌릴 수 없습니다.",
"notice.delete.cancel": "취소",
"notice.delete.confirm": "삭제",
"notice.delete.deleting": "삭제 중...",
};
function getPriorityLabel(priority: number): { langKey: string; variant: "default" | "secondary" | "destructive" | "outline" } {
if (priority >= 3) return { langKey: "notice.priority.high", variant: "destructive" };
if (priority === 2) return { langKey: "notice.priority.medium", variant: "default" };
return { langKey: "notice.priority.low", 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 { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "admin.systemNotices" });
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 || t("notice.error.loadFailed"));
}
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(t("notice.validate.titleRequired"));
return;
}
if (!formData.content.trim()) {
alert(t("notice.validate.contentRequired"));
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 || t("notice.error.saveFailed"));
}
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 || t("notice.error.deleteFailed"));
}
setIsDeleting(false);
};
const columns: RDVColumn<SystemNotice>[] = [
{
key: "title",
label: t("notice.column.title"),
render: (_val, notice) => (
<span className="font-medium">{notice.title}</span>
),
},
{
key: "is_active",
label: t("notice.column.status"),
width: "100px",
render: (_val, notice) => (
<Badge variant={notice.is_active ? "default" : "secondary"}>
{notice.is_active ? t("notice.status.active") : t("notice.status.inactive")}
</Badge>
),
},
{
key: "priority",
label: t("notice.column.priority"),
width: "100px",
render: (_val, notice) => {
const p = getPriorityLabel(notice.priority);
return <Badge variant={p.variant}>{t(p.langKey)}</Badge>;
},
},
{
key: "created_by",
label: t("notice.column.author"),
width: "120px",
hideOnMobile: true,
render: (_val, notice) => (
<span className="text-muted-foreground">{notice.created_by || "-"}</span>
),
},
{
key: "created_at",
label: t("notice.column.createdAt"),
width: "120px",
hideOnMobile: true,
render: (_val, notice) => (
<span className="text-muted-foreground">{formatDate(notice.created_at)}</span>
),
},
];
const cardFields: RDVCardField<SystemNotice>[] = [
{
label: t("notice.card.author"),
render: (notice) => notice.created_by || "-",
},
{
label: t("notice.card.createdAt"),
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">{t("notice.page.title")}</h1>
<p className="text-sm text-muted-foreground">
{t("notice.page.description")}
</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">{t("notice.error.title")}</p>
<button
onClick={() => setErrorMsg(null)}
className="text-destructive transition-colors hover:text-destructive/80"
aria-label={t("notice.error.closeLabel")}
>
</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={t("notice.filter.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("notice.filter.all")}</SelectItem>
<SelectItem value="active">{t("notice.filter.active")}</SelectItem>
<SelectItem value="inactive">{t("notice.filter.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={t("notice.filter.searchPlaceholder")}
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">
{t("notice.list.total")} <span className="font-semibold text-foreground">{filteredNotices.length}</span> {t("notice.list.countSuffix")}
</span>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={loadNotices}
aria-label={t("notice.list.refreshLabel")}
>
<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" />
{t("notice.button.create")}
</Button>
</div>
</div>
<ResponsiveDataView<SystemNotice>
data={filteredNotices}
columns={columns}
keyExtractor={(n) => String(n.id)}
isLoading={isLoading}
emptyMessage={t("notice.list.empty")}
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={t("notice.button.editLabel")}
>
<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={t("notice.button.deleteLabel")}
>
<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 ? t("notice.status.active") : t("notice.status.inactive")}
</Badge>
<Badge variant={p.variant}>{t(p.langKey)}</Badge>
</span>
);
}}
cardFields={cardFields}
actionsLabel={t("notice.column.actions")}
actionsWidth="120px"
renderActions={(notice) => (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleOpenEdit(notice)}
aria-label={t("notice.button.editLabel")}
>
<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={t("notice.button.deleteLabel")}
>
<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 ? t("notice.form.titleEdit") : t("notice.form.titleCreate")}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{editTarget ? t("notice.form.descriptionEdit") : t("notice.form.descriptionCreate")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="notice-title" className="text-xs sm:text-sm">
{t("notice.form.titleLabel")} <span className="text-destructive">*</span>
</Label>
<Input
id="notice-title"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t("notice.form.titlePlaceholder")}
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">
{t("notice.form.contentLabel")} <span className="text-destructive">*</span>
</Label>
<Textarea
id="notice-content"
value={formData.content}
onChange={(e) => setFormData((prev) => ({ ...prev, content: e.target.value }))}
placeholder={t("notice.form.contentPlaceholder")}
className="mt-1 min-h-[120px] text-xs sm:text-sm"
/>
</div>
<div>
<Label htmlFor="notice-priority" className="text-xs sm:text-sm">
{t("notice.form.priorityLabel")}
</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={t("notice.form.priorityPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("notice.priority.low")}</SelectItem>
<SelectItem value="2">{t("notice.priority.medium")}</SelectItem>
<SelectItem value="3">{t("notice.priority.high")}</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">
{t("notice.form.activeLabel")}
</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"
>
{t("notice.form.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSaving ? t("notice.form.saving") : t("notice.form.save")}
</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">{t("notice.delete.title")}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{t("notice.delete.description")}
<br />{t("notice.delete.descriptionIrreversible")}
<br />
<span className="mt-2 block font-medium text-foreground">
&quot;{deleteTarget?.title}&quot;
</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"
>
{t("notice.delete.cancel")}
</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 ? t("notice.delete.deleting") : t("notice.delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ScrollToTop />
</div>
);
}