add terms management for signup agreements
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 14:21:39 +09:00
parent 81b18721f7
commit 09480540ec
5 changed files with 444 additions and 2 deletions
+379
View File
@@ -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} />
</>
);
}