import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table'; import { useToast } from '../../components/toast'; import { Button, Modal } from '../../components/ui'; import { PageTitle } from '../../layout/AppLayout'; import { formatDateTime } from '../../lib/date-format'; import { listApps } from '../applications/application-api'; import { createLauncherNotice, deleteLauncherNotice, loadLauncherNotices, updateLauncherNotice, type LauncherNotice } from './operations-api'; import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; import type { AppWithComponents } from '../../types/application'; type NoticeForm = { appKey: string; title: string; body: string; linkUrl: string; exposureEnabled: boolean; startsAt: string; endsAt: string; priority: string; isActive: boolean; }; const emptyForm: NoticeForm = { appKey: 'Launcher', title: '', body: '', linkUrl: '', exposureEnabled: false, startsAt: '', endsAt: '', priority: '0', isActive: true }; function formatNoticeDateTime(value?: string | null) { return formatDateTime(value); } function toDateTimeInputValue(value?: string | null) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); const hh = String(date.getHours()).padStart(2, '0'); const mi = String(date.getMinutes()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; } function toApiDateTime(value: string) { return value ? new Date(value).toISOString() : undefined; } function exposureCell(row: LauncherNotice) { if (!row.startsAt && !row.endsAt) { return 상시; } return (

{row.startsAt ? formatNoticeDateTime(row.startsAt) : '-'}

~ {row.endsAt ? formatNoticeDateTime(row.endsAt) : '-'}

); } function NoticeCreator({ notice }: { notice: LauncherNotice }) { const [failed, setFailed] = useState(false); const name = notice.createdByName || notice.createdByEmail || 'admin'; const avatarUrl = notice.createdByAvatarUrl || ''; const initial = name.trim().charAt(0).toUpperCase() || 'A'; return (
{avatarUrl && !failed ? ( setFailed(true)} /> ) : ( {initial} )}

{name}

{formatNoticeDateTime(notice.createdAt)}

); } function toForm(notice: LauncherNotice): NoticeForm { return { appKey: notice.appKey, title: notice.title, body: notice.body, linkUrl: notice.linkUrl ?? '', exposureEnabled: Boolean(notice.startsAt || notice.endsAt), startsAt: toDateTimeInputValue(notice.startsAt), endsAt: toDateTimeInputValue(notice.endsAt), priority: String(notice.priority ?? 0), isActive: notice.isActive }; } function isLauncherNoticeKey(appKey: string) { return appKey.toLowerCase() === 'launcher' || appKey.toLowerCase() === 'login'; } export function NoticesPage() { const { toast } = useToast(); const [notices, setNotices] = useState([]); const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [menuNotice, setMenuNotice] = useState<{ id: string; x: number; y: number } | null>(null); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); const loadingVisible = useMinimumLoading(loading); const refresh = () => { setLoading(true); Promise.all([ loadLauncherNotices(), listApps().catch(() => []) ]) .then(([items, appItems]) => { setNotices(items); setApps(appItems); setFailed(false); }) .catch((error: Error) => { setNotices([]); setFailed(true); toast({ type: 'error', title: '공지사항 조회 실패', message: error.message }); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { if (!menuNotice) return; const close = () => setMenuNotice(null); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMenuNotice(null); }; window.addEventListener('click', close); window.addEventListener('keydown', closeOnEscape); return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', closeOnEscape); }; }, [menuNotice]); const appNameByKey = useMemo(() => { const map = new Map([ ['launcher', 'Launcher'], ['login', 'Launcher'] ]); apps.forEach((app) => map.set(app.key, app.name)); return map; }, [apps]); const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey; const rows = useMemo( () => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })), [appNameByKey, notices] ); const openCreate = () => { setEditing(null); setForm(emptyForm); setModalOpen(true); }; const openEdit = (notice: LauncherNotice) => { setMenuNotice(null); setEditing(notice); setForm(toForm(notice)); setModalOpen(true); }; const closeModal = () => { setModalOpen(false); setEditing(null); setForm(emptyForm); }; const payload = () => ({ appKey: form.appKey.trim(), title: form.title.trim(), body: form.body.trim(), linkUrl: form.linkUrl.trim(), startsAt: form.exposureEnabled ? toApiDateTime(form.startsAt) : undefined, endsAt: form.exposureEnabled ? toApiDateTime(form.endsAt) : undefined, priority: Number(form.priority) || 0, isActive: form.isActive }); const submit = () => { if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return; setSaving(true); const request = editing ? updateLauncherNotice(editing.id, payload()) : createLauncherNotice(payload()); request .then(() => { toast({ type: 'success', message: editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.' }); closeModal(); refresh(); }) .catch((error: Error) => { toast({ type: 'error', title: editing ? '공지사항 수정 실패' : '공지사항 등록 실패', message: error.message }); }) .finally(() => setSaving(false)); }; const removeNotice = (notice: LauncherNotice) => { setMenuNotice(null); setDeleteTarget(notice); }; const confirmRemoveNotice = () => { if (!deleteTarget) return; setSaving(true); deleteLauncherNotice(deleteTarget.id) .then(() => { toast({ type: 'success', message: '공지사항을 삭제했습니다.' }); setDeleteTarget(null); closeModal(); refresh(); }) .catch((error: Error) => { toast({ type: 'error', title: '공지사항 삭제 실패', message: error.message }); }) .finally(() => setSaving(false)); }; const columns = useMemo>>( () => [ { key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => {row.no} }, { key: 'title', header: '공지', className: 'w-[300px] px-5 py-3 font-medium', cell: (row) => (

{row.title}

{row.body}

) }, { key: 'appKey', header: '대상 앱', cell: (row) => }, { key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell }, { key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => }, { key: 'actions', header: '', className: 'w-20 px-5 py-3 font-medium', cell: (row) => (
) } ], [menuNotice?.id] ); const filters = useMemo>>( () => [ { key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey } ], [rows] ); return ( <> 추가 } /> {failed ? (
공지사항 데이터를 불러오지 못했습니다.
) : null} `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`} getRowKey={(row) => row.id} pageSize={6} minWidth={1040} loading={loading} emptyMessage="등록된 공지사항이 없습니다." /> {menuNotice ? (() => { const row = rows.find((item) => item.id === menuNotice.id); if (!row) return null; return (
event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuNotice.x, top: menuNotice.y, transform: 'translateX(-100%)' }}>
); })() : null} {editing ? '공지사항 수정' : '공지사항 추가'} } onClose={closeModal} className="!max-w-2xl" footer={ <> } >
{ event.preventDefault(); submit(); }}>