diff --git a/src/App.tsx b/src/App.tsx index dc0ee9a..fdf25ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import { createAdminSshKey } from './pages/operations/operations-api'; import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage'; import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage'; import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage'; +import { TermsPage as TermsApiPage } from './pages/operations/TermsPage'; import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage'; import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage'; import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage'; @@ -81,6 +82,7 @@ type ViewKey = | 'launcherLogs' | 'launcherApps' | 'notices' + | 'terms' | 'appManagement' | 'appRepositories' | 'appSources' @@ -118,6 +120,7 @@ const viewByLabel: Record = { 구성요소: 'launcherComponents', '앱 연결': 'launcherApps', 공지사항: 'notices', + 약관: 'terms', '앱 관리': 'appManagement', '저장소 관리': 'appRepositories', '소스 관리': 'appSources', @@ -163,6 +166,7 @@ const pathByView: Record = { launcherLogs: '/launcher/logs', launcherApps: '/launcher/apps', notices: '/operations/notices', + terms: '/operations/terms', appManagement: '/applications/apps', appRepositories: '/applications/repositories', appSources: '/applications/sources', @@ -2296,6 +2300,8 @@ function App() { return ; case 'notices': return ; + case 'terms': + return ; case 'adminManagement': if (!isOwner) return null; return ; diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx index 6918f01..6afccc8 100644 --- a/src/components/data-table.tsx +++ b/src/components/data-table.tsx @@ -7,6 +7,7 @@ export type DataTableColumn = { header: string; className?: string; width?: number; + flexible?: boolean; cell: (row: T) => ReactNode; }; @@ -99,7 +100,10 @@ export function DataTableCard({ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); const currentPage = Math.min(serverPagination?.page ?? page, totalPages); const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); - const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1; + const configuredFlexibleColumnIndex = columns.findIndex((column) => column.flexible); + const flexibleColumnIndex = configuredFlexibleColumnIndex >= 0 + ? configuredFlexibleColumnIndex + : columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1; const updateFilter = (key: string, value: string) => { if (serverPagination?.onFilterChange) { diff --git a/src/data/mock.ts b/src/data/mock.ts index ec3ad27..18896eb 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -10,6 +10,7 @@ import { Megaphone, MonitorUp, Network, + ScrollText, ServerCog, ShieldCheck, Users @@ -106,7 +107,8 @@ export const navGroups = [ { label: '조직 관리', icon: Boxes }, { label: '사용자/세션', icon: Users }, { label: '라이센스', icon: FileKey }, - { label: '공지사항', icon: Megaphone } + { label: '공지사항', icon: Megaphone }, + { label: '약관', icon: ScrollText } ] }, { diff --git a/src/pages/operations/TermsPage.tsx b/src/pages/operations/TermsPage.tsx new file mode 100644 index 0000000..46eb630 --- /dev/null +++ b/src/pages/operations/TermsPage.tsx @@ -0,0 +1,379 @@ +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 { + createTerm, + deleteTerm, + loadTermsPage, + updateTerm, + type LauncherTerm +} from './operations-api'; +import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; + +type TermForm = { + key: string; + body: string; + isRequired: boolean; +}; + +const termKeyOptions = [ + { value: 'terms', label: '이용약관' }, + { value: 'privacy', label: '개인정보 처리방침' } +]; + +const emptyForm: TermForm = { + key: 'terms', + body: '', + isRequired: true +}; + +function termKeyLabel(key: string) { + return termKeyOptions.find((option) => option.value === key)?.label ?? key; +} + +function termKeyTone(key: string) { + return key === 'privacy' ? 'blue' as const : 'violet' as const; +} + +function TermCreator({ term }: { term: LauncherTerm }) { + const [failed, setFailed] = useState(false); + const name = term.createdByName || term.createdByEmail || '-'; + const avatarUrl = term.createdByAvatarUrl || ''; + const initial = name.trim().charAt(0).toUpperCase() || 'A'; + + return ( +
+ {avatarUrl && !failed ? ( + setFailed(true)} + /> + ) : ( + + {initial} + + )} +
+

{name}

+

{formatDateTime(term.createdAt)}

+
+
+ ); +} + +function toForm(term: LauncherTerm): TermForm { + return { + key: term.key, + body: term.body, + isRequired: term.isRequired + }; +} + +export function TermsPage() { + const { toast } = useToast(); + const [terms, setTerms] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [keyword, setKeyword] = useState(''); + const [filterValues, setFilterValues] = 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 [menuTerm, setMenuTerm] = 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); + loadTermsPage({ page, limit: 6, keyword, key: filterValues.key }) + .then((result) => { + setTerms(result.items); + setTotal(result.total); + setFailed(false); + }) + .catch((error: Error) => { + setTerms([]); + setTotal(0); + setFailed(true); + toast({ type: 'error', title: '약관 조회 실패', message: error.message }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(); + }, [page, keyword, filterValues.key]); + + useEffect(() => { + if (!menuTerm) return; + + const close = () => setMenuTerm(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMenuTerm(null); + }; + + window.addEventListener('click', close); + window.addEventListener('keydown', closeOnEscape); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('keydown', closeOnEscape); + }; + }, [menuTerm]); + + const rows = useMemo( + () => terms.map((term) => ({ ...term, no: term.no ?? 0, keyLabel: termKeyLabel(term.key) })), + [terms] + ); + + const openCreate = () => { + setEditing(null); + setForm(emptyForm); + setModalOpen(true); + }; + + const openEdit = (term: LauncherTerm) => { + setMenuTerm(null); + setEditing(term); + setForm(toForm(term)); + setModalOpen(true); + }; + + const closeModal = () => { + setModalOpen(false); + setEditing(null); + setForm(emptyForm); + }; + + const payload = () => ({ + key: form.key.trim(), + body: form.body.trim(), + isRequired: form.isRequired + }); + + const submit = () => { + if (!form.key.trim() || !form.body.trim()) return; + setSaving(true); + const request = editing ? updateTerm(editing.id, payload()) : createTerm(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 removeTerm = (term: LauncherTerm) => { + setMenuTerm(null); + setDeleteTarget(term); + }; + + const confirmRemoveTerm = () => { + if (!deleteTarget) return; + setSaving(true); + deleteTerm(deleteTarget.id) + .then(() => { + toast({ type: 'success', message: '약관을 삭제했습니다.' }); + setDeleteTarget(null); + 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: 'key', header: '약관명', width: 160, className: 'px-5 py-3', cell: (row) => }, + { + key: 'body', + header: '본문', + flexible: true, + className: 'px-5 py-3 font-medium', + cell: (row) =>

{row.body}

+ }, + { key: 'required', header: '필수 여부', className: 'w-24 px-5 py-3', cell: (row) => }, + { 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) => ( +
+ +
+ ) + } + ], + [] + ); + + const filters = useMemo>>( + () => [ + { + key: 'key', + label: '약관명', + options: termKeyOptions, + getValue: (row) => row.key + } + ], + [] + ); + + return ( + <> + + + 추가 + + } + /> + {failed ? ( +
+ 약관 데이터를 불러오지 못했습니다. +
+ ) : null} + `${row.body} ${row.key} ${row.keyLabel}`} + getRowKey={(row) => row.id} + pageSize={6} + minWidth={980} + loading={loading} + emptyMessage="등록된 약관이 없습니다." + serverPagination={{ + page, + total, + onPageChange: setPage, + onSearchChange: (value) => { + setKeyword(value); + setPage(1); + }, + filterValues, + onFilterChange: (key, value) => { + setFilterValues((prev) => ({ ...prev, [key]: value })); + setPage(1); + } + }} + /> + + {menuTerm ? (() => { + const row = rows.find((item) => item.id === menuTerm.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: menuTerm.x, top: menuTerm.y, transform: 'translateX(-100%)' }}> + + +
+ ); + })() : null} + + + + {editing ? '약관 수정' : '약관 추가'} + + } + onClose={closeModal} + className="!max-w-2xl" + footer={ + <> + + + + } + > +
{ event.preventDefault(); submit(); }}> +
+ + +
+