This commit is contained in:
+10
-4
@@ -33,6 +33,7 @@ import { LicensesPage as LicensesApiPage } from './pages/operations/LicensesPage
|
|||||||
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
import { NoticesPage as NoticesApiPage } from './pages/operations/NoticesPage';
|
||||||
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
import { OrganizationsPage as OrganizationsApiPage } from './pages/operations/OrganizationsPage';
|
||||||
import { TermsPage as TermsApiPage } from './pages/operations/TermsPage';
|
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 { UserSessionsPage as UserSessionsApiPage } from './pages/operations/UserSessionsPage';
|
||||||
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
import { RuntimeConfigPage as RuntimeConfigApiPage } from './pages/runtime/RuntimeConfigPage';
|
||||||
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
import { RuntimeDeviceControlPage } from './pages/runtime/RuntimeDeviceControlPage';
|
||||||
@@ -79,6 +80,7 @@ type ViewKey =
|
|||||||
| 'launcherApps'
|
| 'launcherApps'
|
||||||
| 'notices'
|
| 'notices'
|
||||||
| 'terms'
|
| 'terms'
|
||||||
|
| 'manuals'
|
||||||
| 'appManagement'
|
| 'appManagement'
|
||||||
| 'appRepositories'
|
| 'appRepositories'
|
||||||
| 'appSources'
|
| 'appSources'
|
||||||
@@ -113,6 +115,7 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'앱 연결': 'launcherApps',
|
'앱 연결': 'launcherApps',
|
||||||
공지사항: 'notices',
|
공지사항: 'notices',
|
||||||
약관: 'terms',
|
약관: 'terms',
|
||||||
|
매뉴얼: 'manuals',
|
||||||
'앱 관리': 'appManagement',
|
'앱 관리': 'appManagement',
|
||||||
'저장소 관리': 'appRepositories',
|
'저장소 관리': 'appRepositories',
|
||||||
'소스 관리': 'appSources',
|
'소스 관리': 'appSources',
|
||||||
@@ -137,7 +140,7 @@ const viewByLabel: Record<string, ViewKey> = {
|
|||||||
'장비 제어': 'runtimeDeviceControl',
|
'장비 제어': 'runtimeDeviceControl',
|
||||||
'관리자 관리': 'adminManagement',
|
'관리자 관리': 'adminManagement',
|
||||||
'조직 관리': 'organizations',
|
'조직 관리': 'organizations',
|
||||||
'사용자/세션': 'userSessions',
|
'사용자 관리': 'userSessions',
|
||||||
라이센스: 'licenses'
|
라이센스: 'licenses'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
launcherApps: '/launcher/apps',
|
launcherApps: '/launcher/apps',
|
||||||
notices: '/operations/notices',
|
notices: '/operations/notices',
|
||||||
terms: '/operations/terms',
|
terms: '/operations/terms',
|
||||||
|
manuals: '/operations/manuals',
|
||||||
appManagement: '/applications/apps',
|
appManagement: '/applications/apps',
|
||||||
appRepositories: '/applications/repositories',
|
appRepositories: '/applications/repositories',
|
||||||
appSources: '/applications/sources',
|
appSources: '/applications/sources',
|
||||||
@@ -1713,7 +1717,7 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="사용자/세션"
|
title="사용자 관리"
|
||||||
description="실시간 접근 상태와 세션 제어 항목입니다."
|
description="실시간 접근 상태와 세션 제어 항목입니다."
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
@@ -1724,8 +1728,8 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
if (!isTrustedClick(event)) return;
|
if (!isTrustedClick(event)) return;
|
||||||
onMore();
|
onMore();
|
||||||
}}
|
}}
|
||||||
title="사용자/세션으로 이동"
|
title="사용자 관리로 이동"
|
||||||
aria-label="사용자/세션으로 이동"
|
aria-label="사용자 관리로 이동"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2286,6 +2290,8 @@ function App() {
|
|||||||
return <NoticesApiPage />;
|
return <NoticesApiPage />;
|
||||||
case 'terms':
|
case 'terms':
|
||||||
return <TermsApiPage />;
|
return <TermsApiPage />;
|
||||||
|
case 'manuals':
|
||||||
|
return <ManualsApiPage />;
|
||||||
case 'adminManagement':
|
case 'adminManagement':
|
||||||
if (!isOwner) return null;
|
if (!isOwner) return null;
|
||||||
return <AdminManagementApiPage />;
|
return <AdminManagementApiPage />;
|
||||||
|
|||||||
+3
-2
@@ -95,10 +95,11 @@ export const navGroups = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', icon: ShieldCheck },
|
{ label: '관리자 관리', icon: ShieldCheck },
|
||||||
{ label: '조직 관리', icon: Boxes },
|
{ label: '조직 관리', icon: Boxes },
|
||||||
{ label: '사용자/세션', icon: Users },
|
{ label: '사용자 관리', icon: Users },
|
||||||
{ label: '라이센스', icon: FileKey },
|
{ label: '라이센스', icon: FileKey },
|
||||||
{ label: '공지사항', icon: Megaphone },
|
{ label: '공지사항', icon: Megaphone },
|
||||||
{ label: '약관', icon: ScrollText }
|
{ label: '약관', icon: ScrollText },
|
||||||
|
{ label: '매뉴얼', icon: ScrollText }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||||
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
{ 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/licenses', icon: FileKey },
|
||||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>;
|
||||||
|
|
||||||
|
const emptyForm: ManualForm = { appKey: '', title: '', body: '', isPublished: true, sortOrder: 0 };
|
||||||
|
|
||||||
|
export function ManualsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [documents, setDocuments] = useState<ManualDocument[]>([]);
|
||||||
|
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<ManualDocument | null>(null);
|
||||||
|
const [form, setForm] = useState<ManualForm>(emptyForm);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ManualDocument | null>(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<Array<DataTableColumn<ManualDocument>>>(() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
|
{ 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) => <p className="truncate">{row.body}</p> },
|
||||||
|
{ key: 'published', header: '공개', width: 100, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.isPublished ? '공개' : '비공개'} tone={row.isPublished ? 'green' : 'neutral'} /> },
|
||||||
|
{ 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) => <div className="relative flex justify-end"><Button variant="ghost" className="size-8 px-0" title="메뉴" aria-label="메뉴" onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); setMenu((previous) => previous?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }); }}><MoreHorizontal className="size-4" /></Button></div> },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<PageTitle eyebrow="운영" title="매뉴얼" description="런처 사용 매뉴얼의 Markdown 문서를 관리합니다." actions={<Button className="h-8 px-3 text-[13px]" onClick={openCreate} disabled={!selectedAppKey}><Plus className="size-4" />추가</Button>} />
|
||||||
|
{failed && <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">매뉴얼 데이터를 불러오지 못했습니다.</div>}
|
||||||
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={(appId) => { selectApp(appId); setPage(1); }} />
|
||||||
|
<DataTableCard title="목록" rows={documents} columns={columns} searchPlaceholder="문서명, 본문 검색" getSearchText={(row) => `${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 <div onClick={(event) => 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%)' }}>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(document)}>수정</button>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => { setMenu(null); setDeleteTarget(document); }}>삭제</button>
|
||||||
|
</div>;
|
||||||
|
})()}
|
||||||
|
<Modal open={modalOpen} title={<span className="inline-flex items-center gap-2"><Pencil className="size-4 text-primary" />{editing ? '매뉴얼 수정' : '매뉴얼 추가'}</span>} onClose={closeModal} className="!max-w-3xl" footer={<><Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button><Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>{saving ? '저장 중' : '저장'}</Button></>}>
|
||||||
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_96px]">
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">문서명 <b className="text-foreground">*</b></span><input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} /></label>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">순서</span><input type="number" min="0" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.sortOrder} onChange={(event) => setForm((prev) => ({ ...prev, sortOrder: Number(event.target.value) || 0 }))} /></label>
|
||||||
|
</div>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span><textarea className="mt-2 min-h-72 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} /></label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-foreground"><input type="checkbox" checked={form.isPublished} onChange={(event) => setForm((prev) => ({ ...prev, isPublished: event.target.checked }))} />공개</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
<Modal open={Boolean(deleteTarget)} title={<span className="inline-flex items-center gap-2"><Trash2 className="size-4 text-rose-300" />매뉴얼 삭제</span>} onClose={() => setDeleteTarget(null)} footer={<><Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button><Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button></>}><p className="pb-6 text-sm text-foreground">매뉴얼 문서를 삭제할까요?</p></Modal>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -187,7 +187,7 @@ export function UserSessionsPage() {
|
|||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="운영"
|
eyebrow="운영"
|
||||||
title="사용자/세션"
|
title="사용자 관리"
|
||||||
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
@@ -198,7 +198,7 @@ export function UserSessionsPage() {
|
|||||||
/>
|
/>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
사용자/세션 데이터를 불러오지 못했습니다.
|
사용자 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
@@ -212,7 +212,7 @@ export function UserSessionsPage() {
|
|||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={900}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
emptyMessage="표시할 사용자가 없습니다."
|
||||||
serverPagination={{
|
serverPagination={{
|
||||||
page,
|
page,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ export type LauncherTerm = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ManualDocument = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
appKey: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
isPublished: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||||
appPermissions?: Array<string | { appId: string }>;
|
appPermissions?: Array<string | { appId: string }>;
|
||||||
@@ -383,6 +395,24 @@ export function deleteTerm(termId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadManualDocumentsPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<ManualDocument>> {
|
||||||
|
const response = await apiRequest<{ documents?: ManualDocument[]; total?: number; page?: number; limit?: number }>(`/manuals${pageQuery(params)}`);
|
||||||
|
const items = response.documents ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManualDocument(payload: Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>('/manuals', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManualDocument(id: string, payload: Partial<Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>(`/manuals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteManualDocument(id: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/manuals/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export type LicensePlan = {
|
export type LicensePlan = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user