add manual manage..
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-14 10:45:04 +09:00
parent 66049c78fd
commit 145ca008dd
6 changed files with 172 additions and 10 deletions
+10 -4
View File
@@ -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
View File
@@ -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 }
] ]
}, },
{ {
+1 -1
View File
@@ -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 }
] ]
+125
View File
@@ -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} />
</>;
}
+3 -3
View File
@@ -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,
+30
View File
@@ -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;