init studio admin frontend
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
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 { Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateOnly } from '../../lib/date-format';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import {
|
||||
assignUserLicense,
|
||||
loadLicensePlans,
|
||||
loadLicenseUsers,
|
||||
revokeUserLicense,
|
||||
updateUserLicense,
|
||||
type AuthLicenseUser,
|
||||
type LicensePlan
|
||||
} from './operations-api';
|
||||
|
||||
type LicenseAssignmentRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: string;
|
||||
name: string;
|
||||
role: string;
|
||||
code: string;
|
||||
licenseName: string;
|
||||
startsAt: string;
|
||||
expiresAt?: string | null;
|
||||
assignedBy?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(dateValue: string, days?: number | null) {
|
||||
if (!dateValue || !days) return '';
|
||||
const date = new Date(`${dateValue}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string | null) {
|
||||
if (value == null) return '-';
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed === 'null' || trimmed === 'undefined') return '-';
|
||||
return formatDateOnly(trimmed);
|
||||
}
|
||||
|
||||
function toDateInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function assignmentRows(users: AuthLicenseUser[]) {
|
||||
return users.flatMap((user) =>
|
||||
user.licenses.map((license) => ({
|
||||
id: license.id,
|
||||
userId: user.id,
|
||||
user: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
code: license.license.code,
|
||||
licenseName: license.license.name,
|
||||
startsAt: license.startsAt,
|
||||
expiresAt: license.expiresAt,
|
||||
assignedBy: license.assignedById,
|
||||
status: license.status
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function LicensesPage() {
|
||||
const { toast } = useToast();
|
||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [assignForm, setAssignForm] = useState({
|
||||
userId: '',
|
||||
licenseCode: '',
|
||||
startsAt: today(),
|
||||
expiresAt: ''
|
||||
});
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const rows = useMemo(() => assignmentRows(users), [users]);
|
||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||
const plan = plans.find((item) => item.code === licenseCode);
|
||||
return addDays(startsAt, plan?.defaultDurationDays);
|
||||
};
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = userKeyword.trim().toLowerCase();
|
||||
if (!keyword) return [];
|
||||
return users.filter((user) => `${user.email} ${user.name}`.toLowerCase().includes(keyword));
|
||||
}, [userKeyword, users]);
|
||||
const selectedUser = useMemo(() => users.find((user) => user.id === assignForm.userId), [assignForm.userId, users]);
|
||||
const selectedUserCurrentLicense = selectedUser?.currentLicense ?? null;
|
||||
const cannotAssignBecauseActive = Boolean(!editingRow && selectedUserCurrentLicense);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||
setPlans(nextPlans);
|
||||
setUsers(nextUsers);
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||
expiresAt: prev.expiresAt || addDays(prev.startsAt, nextPlans.find((plan) => plan.code === (prev.licenseCode || nextPlans[0]?.code || ''))?.defaultDurationDays)
|
||||
}));
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 정보를 불러오지 못했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRow) return;
|
||||
|
||||
const close = () => setMenuRow(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuRow(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuRow]);
|
||||
|
||||
const openAssignModal = () => {
|
||||
const startsAt = today();
|
||||
const licenseCode = assignForm.licenseCode || plans[0]?.code || '';
|
||||
setEditingRow(null);
|
||||
setAssignForm((prev) => ({
|
||||
userId: '',
|
||||
licenseCode,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(licenseCode, startsAt)
|
||||
}));
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (row: LicenseAssignmentRow) => {
|
||||
const startsAt = toDateInputValue(row.startsAt) || today();
|
||||
setMenuRow(null);
|
||||
setEditingRow(row);
|
||||
setAssignForm({
|
||||
userId: row.userId,
|
||||
licenseCode: row.code,
|
||||
startsAt,
|
||||
expiresAt: toDateInputValue(row.expiresAt)
|
||||
});
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const closeAssignModal = () => {
|
||||
setAssignOpen(false);
|
||||
setEditingRow(null);
|
||||
setUserKeyword('');
|
||||
};
|
||||
|
||||
const submitAssign = async () => {
|
||||
if (!assignForm.userId || !assignForm.licenseCode) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
licenseCode: assignForm.licenseCode,
|
||||
startsAt: assignForm.startsAt || undefined,
|
||||
expiresAt: assignForm.expiresAt || undefined
|
||||
};
|
||||
if (editingRow) {
|
||||
await updateUserLicense(editingRow.id, payload);
|
||||
} else {
|
||||
await assignUserLicense(assignForm.userId, payload);
|
||||
}
|
||||
toast({ type: 'success', message: editingRow ? '라이센스를 수정했습니다.' : '라이센스를 부여했습니다.' });
|
||||
closeAssignModal();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : editingRow ? '라이센스 수정에 실패했습니다.' : '라이센스 부여에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await revokeUserLicense(deleteTarget.id);
|
||||
toast({ type: 'success', message: '라이센스를 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: '사용자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="text-foreground">{row.user}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.name}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'licenseName', header: '라이선스명', cell: (row) => <span className="text-muted-foreground">{row.licenseName}</span> },
|
||||
{ key: 'startsAt', header: '시작일', cell: (row) => <span className="text-muted-foreground">{formatDateOnly(row.startsAt)}</span> },
|
||||
{ key: 'expiresAt', header: '만료일', cell: (row) => <span className="text-muted-foreground">{formatOptionalDate(row.expiresAt)}</span> },
|
||||
{ key: 'assignedBy', header: '부여자', cell: (row) => <span className="text-muted-foreground">{row.assignedBy || '-'}</span> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right 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();
|
||||
setMenuRow((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<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
||||
],
|
||||
[plans, rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="라이센스"
|
||||
description="공통 인증 DB 기준으로 라이센스 플랜과 사용자 부여 이력을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||
<Plus className="size-4" />
|
||||
라이센스 부여
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.code} className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">{plan.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||
</div>
|
||||
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">부여</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.assignedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">활성</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.activeUserCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">만료임박</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.expiringSoonCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">기본 기간 {plan.defaultDurationDays ?? '-'}일</p>
|
||||
</Card>
|
||||
))}
|
||||
{!loading && plans.length === 0 ? (
|
||||
<Card className="flex min-h-40 items-center justify-center p-5 text-sm text-muted-foreground xl:col-span-3">
|
||||
등록된 라이센스 플랜이 없습니다.
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTableCard
|
||||
title="부여 이력"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 라이선스, 부여자 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.name} ${row.licenseName} ${row.assignedBy ?? ''} ${row.status}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={980}
|
||||
loading={loading}
|
||||
emptyMessage="부여된 라이센스가 없습니다."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{menuRow ? (() => {
|
||||
const row = rows.find((item) => item.id === menuRow.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: menuRow.x, top: menuRow.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={() => openEditModal(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={() => {
|
||||
setMenuRow(null);
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={assignOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeAssignModal}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeAssignModal} disabled={saving}>취소</Button>
|
||||
<Button disabled={saving || !assignForm.userId || !assignForm.licenseCode || cannotAssignBecauseActive} onClick={submitAssign}>
|
||||
{saving ? '저장 중' : editingRow ? '저장' : '부여'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editingRow ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{editingRow.user}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={userKeyword}
|
||||
onChange={(event) => setUserKeyword(event.target.value)}
|
||||
placeholder="이메일 또는 이름 검색"
|
||||
/>
|
||||
{selectedUser ? (
|
||||
<div className="mt-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{selectedUser.email}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{selectedUser.name || '-'}</p>
|
||||
{selectedUserCurrentLicense ? (
|
||||
<p className="mt-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-200">
|
||||
이미 활성 라이센스가 있습니다. 기존 이력에서 수정하세요.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{userKeyword.trim() ? (
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-border bg-background/80">
|
||||
{filteredUsers.map((user) => {
|
||||
const selected = assignForm.userId === user.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition hover:bg-white/5 ${selected ? 'bg-primary/15 text-foreground' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
setAssignForm((prev) => ({ ...prev, userId: user.id }));
|
||||
setUserKeyword('');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-foreground">{user.email}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.name || '-'}</span>
|
||||
</span>
|
||||
{selected ? <span className="text-xs text-primary">선택됨</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">검색된 사용자가 없습니다.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스</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={assignForm.licenseCode}
|
||||
onChange={(event) => {
|
||||
const licenseCode = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode,
|
||||
expiresAt: calculateExpiresAt(licenseCode, prev.startsAt)
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{plans.map((plan) => <option key={plan.code} value={plan.code}>{plan.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||
<input
|
||||
type="date"
|
||||
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={assignForm.startsAt}
|
||||
onChange={(event) => {
|
||||
const startsAt = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(prev.licenseCode, startsAt)
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">만료일</span>
|
||||
<input
|
||||
type="date"
|
||||
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={assignForm.expiresAt}
|
||||
onChange={(event) => setAssignForm((prev) => ({ ...prev, expiresAt: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</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={revoke} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">라이센스를 삭제할까요?</p>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user