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) => (
)
},
{ 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={
<>
>
}
>
공지사항 삭제
}
onClose={() => setDeleteTarget(null)}
footer={
<>
>
}
>
공지사항을 삭제할까요?
>
);
}