diff --git a/src/App.tsx b/src/App.tsx index f62a504..899f06d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ 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 { ManualsPage as ManualsApiPage } from './pages/operations/ManualsPage'; import { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage'; import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage'; import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage'; @@ -79,6 +80,7 @@ type ViewKey = | 'launcherApps' | 'notices' | 'terms' + | 'manuals' | 'appManagement' | 'appRepositories' | 'appSources' @@ -113,6 +115,7 @@ const viewByLabel: Record = { '앱 연결': 'launcherApps', 공지사항: 'notices', 약관: 'terms', + 매뉴얼: 'manuals', '앱 관리': 'appManagement', '저장소 관리': 'appRepositories', '소스 관리': 'appSources', @@ -137,7 +140,7 @@ const viewByLabel: Record = { '장비 제어': 'runtimeDeviceControl', '관리자 관리': 'adminManagement', '조직 관리': 'organizations', - '사용자/세션': 'userSessions', + '사용자 관리': 'userSessions', 라이센스: 'licenses' }; @@ -155,6 +158,7 @@ const pathByView: Record = { launcherApps: '/launcher/apps', notices: '/operations/notices', terms: '/operations/terms', + manuals: '/operations/manuals', appManagement: '/applications/apps', appRepositories: '/applications/repositories', appSources: '/applications/sources', @@ -1713,7 +1717,7 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow return ( @@ -2286,6 +2290,8 @@ function App() { return ; case 'terms': return ; + case 'manuals': + return ; case 'adminManagement': if (!isOwner) return null; return ; diff --git a/src/data/mock.ts b/src/data/mock.ts index bb0a8d7..cd367ee 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -95,10 +95,11 @@ export const navGroups = [ items: [ { label: '관리자 관리', icon: ShieldCheck }, { label: '조직 관리', icon: Boxes }, - { label: '사용자/세션', icon: Users }, + { label: '사용자 관리', icon: Users }, { label: '라이센스', icon: FileKey }, { label: '공지사항', icon: Megaphone }, - { label: '약관', icon: ScrollText } + { label: '약관', icon: ScrollText }, + { label: '매뉴얼', icon: ScrollText } ] }, { diff --git a/src/layout/navigation.ts b/src/layout/navigation.ts index 772e87e..8a8d4da 100644 --- a/src/layout/navigation.ts +++ b/src/layout/navigation.ts @@ -99,7 +99,7 @@ export const navGroups: NavGroup[] = [ items: [ { label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck }, { label: '조직 관리', path: '/operations/organizations', icon: Boxes }, - { label: '사용자/세션', path: '/operations/users', icon: Users }, + { label: '사용자 관리', path: '/operations/users', icon: Users }, { label: '라이센스', path: '/operations/licenses', icon: FileKey }, { label: '공지사항', path: '/operations/notices', icon: Megaphone } ] diff --git a/src/pages/operations/ManualsPage.tsx b/src/pages/operations/ManualsPage.tsx new file mode 100644 index 0000000..8a9ff5c --- /dev/null +++ b/src/pages/operations/ManualsPage.tsx @@ -0,0 +1,125 @@ +import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { DataTableCard, type DataTableColumn } 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 { AppSelector, useApps } from '../applications/ApplicationControls'; +import { + createManualDocument, + deleteManualDocument, + loadManualDocumentsPage, + type ManualDocument, + updateManualDocument, +} from './operations-api'; +import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils'; + +type ManualForm = Pick; + +const emptyForm: ManualForm = { appKey: '', title: '', body: '', isPublished: true, sortOrder: 0 }; + +export function ManualsPage() { + const { toast } = useToast(); + const [documents, setDocuments] = useState([]); + const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps(); + const selectedAppKey = selectedApp?.key ?? ''; + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [keyword, setKeyword] = useState(''); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [modalOpen, setModalOpen] = useState(false); + const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [saving, setSaving] = useState(false); + const loadingVisible = useMinimumLoading(loading); + + const refresh = () => { + if (!selectedAppKey) { + setDocuments([]); + setTotal(0); + setLoading(false); + return; + } + setLoading(true); + loadManualDocumentsPage({ page, limit: 6, keyword, appKey: selectedAppKey }) + .then((result) => { setDocuments(result.items); setTotal(result.total); setFailed(false); }) + .catch((error: Error) => { setDocuments([]); setTotal(0); setFailed(true); toast({ type: 'error', title: '매뉴얼 조회 실패', message: error.message }); }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { refresh(); }, [page, keyword, selectedAppKey]); + useEffect(() => { + if (!menu) return; + const close = () => setMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [menu]); + + const openCreate = () => { setEditing(null); setForm({ ...emptyForm, appKey: selectedAppKey }); setModalOpen(true); }; + const openEdit = (document: ManualDocument) => { + setMenu(null); + setEditing(document); + setForm({ appKey: document.appKey, title: document.title, body: document.body, isPublished: document.isPublished, sortOrder: document.sortOrder }); + setModalOpen(true); + }; + const closeModal = () => { setEditing(null); setForm(emptyForm); setModalOpen(false); }; + const submit = () => { + if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return; + setSaving(true); + const payload = { ...form, appKey: form.appKey.trim(), title: form.title.trim(), body: form.body.trim() }; + const request = editing ? updateManualDocument(editing.id, payload) : createManualDocument(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 confirmDelete = () => { + if (!deleteTarget) return; + setSaving(true); + deleteManualDocument(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: 'px-3 py-3 text-center font-medium', cell: (row) => {row.no} }, + { key: 'title', header: '문서명', width: 220, className: 'px-5 py-3 font-medium', cell: (row) => row.title }, + { key: 'body', header: '본문', flexible: true, className: 'px-5 py-3', cell: (row) =>

{row.body}

}, + { key: 'published', header: '공개', width: 100, className: 'px-5 py-3', cell: (row) => }, + { key: 'sortOrder', header: '순서', width: 80, className: 'px-5 py-3 text-center', cell: (row) => row.sortOrder }, + { key: 'createdAt', header: '등록일', width: 160, className: 'px-5 py-3 text-xs text-muted-foreground', cell: (row) => formatDateTime(row.createdAt) }, + { key: 'actions', header: '', className: 'px-5 py-3', cell: (row) =>
}, + ], []); + + return <> + 추가} /> + {failed &&
매뉴얼 데이터를 불러오지 못했습니다.
} + { selectApp(appId); setPage(1); }} /> + `${row.title} ${row.body}`} getRowKey={(row) => row.id} pageSize={6} minWidth={980} loading={loading} emptyMessage="등록된 매뉴얼이 없습니다." serverPagination={{ page, total, onPageChange: setPage, onSearchChange: (value) => { setKeyword(value); setPage(1); } }} /> + {menu && (() => { + const document = documents.find((item) => item.id === menu.id); + if (!document) 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: menu.x, top: menu.y, transform: 'translateX(-100%)' }}> + + +
; + })()} + {editing ? '매뉴얼 수정' : '매뉴얼 추가'}} onClose={closeModal} className="!max-w-3xl" footer={<>}> +
{ event.preventDefault(); submit(); }}> +
+ + +
+