templates2.Admin.Next/components/admin/DealsManager.tsx
w4c-templates c64f7ffa9e
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Admin.Next
2026-10-11 23:25:34 +03:00

274 lines
10 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui';
import { t } from '@/lib/i18n';
import { formatCurrency } from '@/lib/utils';
type Deal = {
id: string;
title: string;
customerId: string | null;
customer: { id: string; name: string } | null;
value: number;
stage: string;
owner: string | null;
createdAt: string;
};
type CustomerOption = { id: string; name: string };
type ListResponse = { items: Deal[]; total: number; page: number; pageSize: number; pages: number };
type FormState = { title: string; customerId: string; value: string; stage: string; owner: string };
const EMPTY: FormState = { title: '', customerId: '', value: '0', stage: 'prospecting', owner: '' };
const STAGES = ['prospecting', 'proposal', 'won', 'lost'];
const stageTone = (stage: string) => (stage === 'won' ? 'success' : stage === 'lost' ? 'danger' : stage === 'proposal' ? 'warning' : 'neutral');
export function DealsManager({ canWrite, canDelete }: { canWrite: boolean; canDelete: boolean }) {
const [items, setItems] = useState<Deal[]>([]);
const [customers, setCustomers] = useState<CustomerOption[]>([]);
const [meta, setMeta] = useState({ page: 1, pages: 1, total: 0 });
const [stage, setStage] = useState('');
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' });
if (stage) params.set('stage', stage);
try {
const res = await fetch(`/api/admin/deals?${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, stage]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void fetch('/api/admin/customers?pageSize=50')
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((d: { items?: CustomerOption[] }) => setCustomers(d.items ?? []));
}, []);
function openCreate() {
setEditingId(null);
setFormError(null);
setForm({ ...EMPTY });
}
function openEdit(d: Deal) {
setEditingId(d.id);
setFormError(null);
setForm({ title: d.title, customerId: d.customerId ?? '', value: String(d.value), stage: d.stage, owner: d.owner ?? '' });
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form) return;
setBusy(true);
setFormError(null);
const payload = { ...form, value: Number(form.value) || 0, customerId: form.customerId || null };
const res = await fetch(editingId ? `/api/admin/deals/${editingId}` : '/api/admin/deals', {
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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(d: Deal) {
if (!window.confirm(`${t('admin.delete')} “${d.title}”? This cannot be undone.`)) return;
setBusy(true);
await fetch(`/api/admin/deals/${d.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.deals')}</h1>
<p className="muted mt-1 text-sm">{meta.total} records</p>
</div>
{canWrite && (
<button type="button" className="btn-primary" onClick={openCreate}>
{t('admin.newDeal')}
</button>
)}
</header>
{!canWrite && <p className="muted text-sm">{t('admin.readOnly')}</p>}
<div className="card flex flex-wrap items-end gap-3">
<label className="text-sm">
{t('admin.stage')}
<select
className="input mt-1"
value={stage}
onChange={(e) => {
setPage(1);
setStage(e.target.value);
}}
>
<option value="">{t('admin.all')}</option>
{STAGES.map((s) => (
<option key={s} value={s}>
{t(`admin.${s}`)}
</option>
))}
</select>
</label>
</div>
{form && (
<form className="card space-y-3" onSubmit={submit}>
<h2 className="text-lg font-semibold">{editingId ? t('admin.editDeal') : t('admin.newDeal')}</h2>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t('admin.dealTitle')}
<input className="input mt-1" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</label>
<label className="text-sm">
{t('admin.customer')}
<select className="input mt-1" value={form.customerId} onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
<option value="">{t('admin.none')}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="text-sm">
{t('admin.value')}
<input
className="input mt-1"
type="number"
min="0"
step="1"
required
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
/>
</label>
<label className="text-sm">
{t('admin.stage')}
<select className="input mt-1" value={form.stage} onChange={(e) => setForm({ ...form, stage: e.target.value })}>
{STAGES.map((s) => (
<option key={s} value={s}>
{t(`admin.${s}`)}
</option>
))}
</select>
</label>
<label className="text-sm">
{t('admin.owner')}
<input className="input mt-1" value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} />
</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.dealTitle')}</th>
<th className="px-4 py-3">{t('admin.customer')}</th>
<th className="px-4 py-3">{t('admin.value')}</th>
<th className="px-4 py-3">{t('admin.stage')}</th>
<th className="px-4 py-3">{t('admin.owner')}</th>
{(canWrite || canDelete) && <th className="px-4 py-3 text-right">{t('admin.actions')}</th>}
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">{d.title}</td>
<td className="px-4 py-3">{d.customer?.name ?? t('admin.none')}</td>
<td className="px-4 py-3">{formatCurrency(d.value)}</td>
<td className="px-4 py-3">
<Badge tone={stageTone(d.stage)}>{t(`admin.${d.stage}`)}</Badge>
</td>
<td className="px-4 py-3">{d.owner || t('admin.none')}</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(d)}>
{t('admin.edit')}
</button>
)}
{canDelete && (
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(d)}>
{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>
);
}