This commit is contained in:
@@ -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<string, ViewKey> = {
|
||||
구성요소: 'launcherComponents',
|
||||
'앱 연결': 'launcherApps',
|
||||
공지사항: 'notices',
|
||||
약관: 'terms',
|
||||
'앱 관리': 'appManagement',
|
||||
'저장소 관리': 'appRepositories',
|
||||
'소스 관리': 'appSources',
|
||||
@@ -163,6 +166,7 @@ const pathByView: Record<ViewKey, string> = {
|
||||
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 <LauncherAppsApiPage />;
|
||||
case 'notices':
|
||||
return <NoticesApiPage />;
|
||||
case 'terms':
|
||||
return <TermsApiPage />;
|
||||
case 'adminManagement':
|
||||
if (!isOwner) return null;
|
||||
return <AdminManagementApiPage />;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type DataTableColumn<T> = {
|
||||
header: string;
|
||||
className?: string;
|
||||
width?: number;
|
||||
flexible?: boolean;
|
||||
cell: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -99,7 +100,10 @@ export function DataTableCard<T>({
|
||||
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) {
|
||||
|
||||
+3
-1
@@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{avatarUrl && !failed ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(term.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<LauncherTerm[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LauncherTerm | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LauncherTerm | null>(null);
|
||||
const [menuTerm, setMenuTerm] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [form, setForm] = useState<TermForm>(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<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||
{ key: 'key', header: '약관명', width: 160, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.keyLabel} tone={termKeyTone(row.key)} /> },
|
||||
{
|
||||
key: 'body',
|
||||
header: '본문',
|
||||
flexible: true,
|
||||
className: 'px-5 py-3 font-medium',
|
||||
cell: (row) => <p className="truncate">{row.body}</p>
|
||||
},
|
||||
{ key: 'required', header: '필수 여부', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.isRequired ? '필수' : '선택'} tone={row.isRequired ? 'amber' : 'neutral'} /> },
|
||||
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <TermCreator term={row} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 font-medium',
|
||||
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();
|
||||
setMenuTerm((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'key',
|
||||
label: '약관명',
|
||||
options: termKeyOptions,
|
||||
getValue: (row) => row.key
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="약관"
|
||||
description="회원가입과 서비스 이용에 필요한 약관을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||
<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>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="약관 본문, 약관명 검색"
|
||||
getSearchText={(row) => `${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 (
|
||||
<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: menuTerm.x, top: menuTerm.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(row)}>
|
||||
수정
|
||||
</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={() => removeTerm(row)}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<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-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.key.trim() || !form.body.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">약관명 <b className="text-foreground">*</b></span>
|
||||
<select 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.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))}>
|
||||
{termKeyOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">필수 여부</span>
|
||||
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={form.isRequired} onChange={(event) => setForm((prev) => ({ ...prev, isRequired: event.target.checked }))} />
|
||||
필수
|
||||
</span>
|
||||
</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-56 w-full rounded-md border border-input bg-background px-3 py-2 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>
|
||||
</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={confirmRemoveTerm} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">약관을 삭제할까요?</p>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,20 @@ export type LauncherNotice = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type LauncherTerm = {
|
||||
id: string;
|
||||
no?: number;
|
||||
key: string;
|
||||
body: string;
|
||||
isRequired: boolean;
|
||||
createdById?: string | null;
|
||||
createdByName?: string | null;
|
||||
createdByEmail?: string | null;
|
||||
createdByAvatarUrl?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||
appPermissions?: Array<string | { appId: string }>;
|
||||
@@ -332,6 +346,43 @@ export function deleteLauncherNotice(noticeId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTermsPage(params?: PageParams & { key?: string }): Promise<PagedResponse<LauncherTerm>> {
|
||||
const response = await apiRequest<{ terms?: LauncherTerm[]; total?: number; page?: number; limit?: number }>(`/terms${pageQuery(params)}`);
|
||||
const items = response.terms ?? [];
|
||||
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||
}
|
||||
|
||||
export function createTerm(payload: {
|
||||
key: string;
|
||||
body: string;
|
||||
isRequired?: boolean;
|
||||
}) {
|
||||
return apiRequest<{ term: LauncherTerm }>('/terms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTerm(
|
||||
termId: string,
|
||||
payload: Partial<{
|
||||
key: string;
|
||||
body: string;
|
||||
isRequired: boolean;
|
||||
}>
|
||||
) {
|
||||
return apiRequest<{ term: LauncherTerm }>(`/terms/${termId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTerm(termId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/terms/${termId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export type LicensePlan = {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
Reference in New Issue
Block a user