299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useCallback, useEffect, useState } from 'react';
|
||
|
|
import { Badge } from '@/components/ui';
|
||
|
|
import { t } from '@/lib/i18n';
|
||
|
|
import { formatDate } from '@/lib/utils';
|
||
|
|
|
||
|
|
type Customer = { id: string; name: string; email: string; company: string | null; status: string; createdAt: string };
|
||
|
|
type ListResponse = { items: Customer[]; total: number; page: number; pageSize: number; pages: number };
|
||
|
|
type FormState = { name: string; email: string; company: string; status: string };
|
||
|
|
|
||
|
|
const EMPTY: FormState = { name: '', email: '', company: '', status: 'active' };
|
||
|
|
const STATUSES = ['active', 'lead', 'churned'];
|
||
|
|
const tone = (status: string) => (status === 'active' ? 'success' : status === 'churned' ? 'danger' : 'warning');
|
||
|
|
|
||
|
|
export function CustomersManager({ canWrite, canDelete }: { canWrite: boolean; canDelete: boolean }) {
|
||
|
|
const [items, setItems] = useState<Customer[]>([]);
|
||
|
|
const [meta, setMeta] = useState({ page: 1, pages: 1, total: 0 });
|
||
|
|
const [searchInput, setSearchInput] = useState('');
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [status, setStatus] = useState('');
|
||
|
|
const [sort, setSort] = useState('newest');
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [form, setForm] = useState<FormState | null>(null);
|
||
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||
|
|
const [formError, setFormError] = useState<string | null>(null);
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
setError(null);
|
||
|
|
const params = new URLSearchParams({ page: String(page), pageSize: '10', sort });
|
||
|
|
if (search) params.set('q', search);
|
||
|
|
if (status) params.set('status', status);
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/admin/customers?${params.toString()}`);
|
||
|
|
if (!res.ok) throw new Error('request failed');
|
||
|
|
const data = (await res.json()) as ListResponse;
|
||
|
|
setItems(data.items);
|
||
|
|
setMeta({ page: data.page, pages: data.pages, total: data.total });
|
||
|
|
} catch {
|
||
|
|
setError(t('common.error'));
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [page, sort, search, status]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void load();
|
||
|
|
}, [load]);
|
||
|
|
|
||
|
|
function openCreate() {
|
||
|
|
setEditingId(null);
|
||
|
|
setFormError(null);
|
||
|
|
setForm({ ...EMPTY });
|
||
|
|
}
|
||
|
|
|
||
|
|
function openEdit(c: Customer) {
|
||
|
|
setEditingId(c.id);
|
||
|
|
setFormError(null);
|
||
|
|
setForm({ name: c.name, email: c.email, company: c.company ?? '', status: c.status });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||
|
|
event.preventDefault();
|
||
|
|
if (!form) return;
|
||
|
|
setBusy(true);
|
||
|
|
setFormError(null);
|
||
|
|
const res = await fetch(editingId ? `/api/admin/customers/${editingId}` : '/api/admin/customers', {
|
||
|
|
method: editingId ? 'PATCH' : 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(form),
|
||
|
|
});
|
||
|
|
setBusy(false);
|
||
|
|
if (!res.ok) {
|
||
|
|
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setForm(null);
|
||
|
|
setEditingId(null);
|
||
|
|
await load();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function remove(c: Customer) {
|
||
|
|
if (!window.confirm(`${t('admin.delete')} “${c.name}”? This cannot be undone.`)) return;
|
||
|
|
setBusy(true);
|
||
|
|
await fetch(`/api/admin/customers/${c.id}`, { method: 'DELETE' });
|
||
|
|
setBusy(false);
|
||
|
|
await load();
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-bold">{t('admin.customers')}</h1>
|
||
|
|
<p className="muted mt-1 text-sm">{meta.total} records</p>
|
||
|
|
</div>
|
||
|
|
{canWrite && (
|
||
|
|
<button type="button" className="btn-primary" onClick={openCreate}>
|
||
|
|
{t('admin.newCustomer')}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{!canWrite && <p className="muted text-sm">{t('admin.readOnly')}</p>}
|
||
|
|
|
||
|
|
<form
|
||
|
|
className="card flex flex-wrap items-end gap-3"
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setPage(1);
|
||
|
|
setSearch(searchInput.trim());
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.search')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
value={searchInput}
|
||
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
||
|
|
placeholder={t('admin.searchPlaceholder')}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.status')}
|
||
|
|
<select
|
||
|
|
className="input mt-1"
|
||
|
|
value={status}
|
||
|
|
onChange={(e) => {
|
||
|
|
setPage(1);
|
||
|
|
setStatus(e.target.value);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="">{t('admin.all')}</option>
|
||
|
|
{STATUSES.map((s) => (
|
||
|
|
<option key={s} value={s}>
|
||
|
|
{t(`admin.${s}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.sort')}
|
||
|
|
<select
|
||
|
|
className="input mt-1"
|
||
|
|
value={sort}
|
||
|
|
onChange={(e) => {
|
||
|
|
setPage(1);
|
||
|
|
setSort(e.target.value);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="newest">{t('admin.sortNewest')}</option>
|
||
|
|
<option value="name">{t('admin.sortName')}</option>
|
||
|
|
<option value="company">{t('admin.sortCompany')}</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<button type="submit" className="btn-secondary">
|
||
|
|
{t('admin.search')}
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
{form && (
|
||
|
|
<form className="card space-y-3" onSubmit={submit}>
|
||
|
|
<h2 className="text-lg font-semibold">{editingId ? t('admin.editCustomer') : t('admin.newCustomer')}</h2>
|
||
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.name')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
required
|
||
|
|
value={form.name}
|
||
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.email')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
type="email"
|
||
|
|
required
|
||
|
|
value={form.email}
|
||
|
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.company')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
value={form.company}
|
||
|
|
onChange={(e) => setForm({ ...form, company: e.target.value })}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.status')}
|
||
|
|
<select
|
||
|
|
className="input mt-1"
|
||
|
|
value={form.status}
|
||
|
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||
|
|
>
|
||
|
|
{STATUSES.map((s) => (
|
||
|
|
<option key={s} value={s}>
|
||
|
|
{t(`admin.${s}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button type="submit" className="btn-primary" disabled={busy}>
|
||
|
|
{t('admin.save')}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn-secondary"
|
||
|
|
onClick={() => {
|
||
|
|
setForm(null);
|
||
|
|
setEditingId(null);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{t('admin.cancel')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||
|
|
{loading ? (
|
||
|
|
<p className="muted text-sm">{t('common.loading')}</p>
|
||
|
|
) : items.length === 0 ? (
|
||
|
|
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead className="muted text-left text-xs uppercase">
|
||
|
|
<tr>
|
||
|
|
<th className="px-4 py-3">{t('admin.name')}</th>
|
||
|
|
<th className="px-4 py-3">{t('admin.email')}</th>
|
||
|
|
<th className="px-4 py-3">{t('admin.company')}</th>
|
||
|
|
<th className="px-4 py-3">{t('admin.status')}</th>
|
||
|
|
<th className="px-4 py-3">{t('admin.created')}</th>
|
||
|
|
{(canWrite || canDelete) && <th className="px-4 py-3 text-right">{t('admin.actions')}</th>}
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{items.map((c) => (
|
||
|
|
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||
|
|
<td className="px-4 py-3 font-medium">{c.name}</td>
|
||
|
|
<td className="px-4 py-3">{c.email}</td>
|
||
|
|
<td className="px-4 py-3">{c.company || t('admin.none')}</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<Badge tone={tone(c.status)}>{t(`admin.${c.status}`)}</Badge>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3">{formatDate(c.createdAt)}</td>
|
||
|
|
{(canWrite || canDelete) && (
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
{canWrite && (
|
||
|
|
<button type="button" className="btn-secondary" onClick={() => openEdit(c)}>
|
||
|
|
{t('admin.edit')}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{canDelete && (
|
||
|
|
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(c)}>
|
||
|
|
{t('admin.delete')}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
)}
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-between text-sm">
|
||
|
|
<button type="button" className="btn-secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||
|
|
{t('admin.prev')}
|
||
|
|
</button>
|
||
|
|
<span className="muted">{t('admin.pageOf', { page: meta.page, pages: meta.pages })}</span>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn-secondary"
|
||
|
|
disabled={page >= meta.pages}
|
||
|
|
onClick={() => setPage((p) => p + 1)}
|
||
|
|
>
|
||
|
|
{t('admin.next')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|