feat(CC-090): add Client Tracker page (contacts with client tag, revenue from contracts, status)
This commit is contained in:
parent
7f0fe42a33
commit
66a4512d19
1 changed files with 182 additions and 0 deletions
182
src/app/dashboard/clients/page.tsx
Normal file
182
src/app/dashboard/clients/page.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Contact { id: string; name: string; organization: string | null; role: string | null; email: string | null; phone: string | null; tags: string[]; createdAt: string; }
|
||||
interface Contract { id: string; title: string; value: number | null; currency: string | null; status: string; tags: string[]; counterparty: string | null; createdAt: string; }
|
||||
|
||||
const CLIENT_TAGS = ['client', 'customer', 'partener-comercial'];
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'text-signal-ok',
|
||||
inactive: 'text-ink-faint',
|
||||
prospect: 'text-warn',
|
||||
churned: 'text-signal-danger',
|
||||
};
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', organization: '', role: '', email: '', status: 'active', industry: '' });
|
||||
|
||||
const { data: contacts = [], isLoading: loadC } = useQuery({
|
||||
queryKey: ['clients-contacts', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: contracts = [] } = useQuery({
|
||||
queryKey: ['clients-contracts', tenantId],
|
||||
queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const clients = useMemo(() =>
|
||||
contacts.filter((c) => c.tags.some((t) => CLIENT_TAGS.includes(t.toLowerCase()))),
|
||||
[contacts]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return clients;
|
||||
const q = search.toLowerCase();
|
||||
return clients.filter((c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.organization ?? '').toLowerCase().includes(q) ||
|
||||
(c.role ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}, [clients, search]);
|
||||
|
||||
function clientContracts(c: Contact) {
|
||||
return contracts.filter((ct) =>
|
||||
(ct.counterparty ?? '').toLowerCase().includes(c.name.toLowerCase()) ||
|
||||
(ct.counterparty ?? '').toLowerCase().includes((c.organization ?? '').toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function clientRevenue(c: Contact): number {
|
||||
return clientContracts(c)
|
||||
.filter((ct) => ct.status === 'signed' || ct.status === 'completed')
|
||||
.reduce((s, ct) => s + (ct.value ?? 0), 0);
|
||||
}
|
||||
|
||||
function clientStatus(c: Contact): string {
|
||||
const tags = c.tags.map((t) => t.toLowerCase());
|
||||
if (tags.includes('inactive') || tags.includes('churned')) return 'churned';
|
||||
if (tags.includes('prospect')) return 'prospect';
|
||||
const cts = clientContracts(c);
|
||||
if (cts.some((ct) => ct.status === 'active' || ct.status === 'signed')) return 'active';
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
const totalRevenue = useMemo(() => clients.reduce((s, c) => s + clientRevenue(c), 0), [clients, contracts]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/contacts', { tenantId, method: 'POST', body: {
|
||||
name: form.name,
|
||||
organization: form.organization || undefined,
|
||||
role: form.role || undefined,
|
||||
email: form.email || undefined,
|
||||
tags: ['client', form.status, ...(form.industry ? [form.industry] : [])].filter(Boolean),
|
||||
}}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients-contacts', tenantId] });
|
||||
setShowAdd(false);
|
||||
setForm({ name: '', organization: '', role: '', email: '', status: 'active', industry: '' });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Client Tracker</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{clients.length} clienți · Total venituri: <span className="font-semibold text-ink">{totalRevenue.toLocaleString('ro-RO')} €</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Client nou
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Client nou</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Nume*" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Organizație" value={form.organization} onChange={(e) => setForm((p) => ({ ...p, organization: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Rol / Funcție" value={form.role} onChange={(e) => setForm((p) => ({ ...p, role: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Email" type="email" value={form.email} onChange={(e) => setForm((p) => ({ ...p, email: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<select value={form.status} onChange={(e) => setForm((p) => ({ ...p, status: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="active">Activ</option>
|
||||
<option value="prospect">Prospect</option>
|
||||
<option value="inactive">Inactiv</option>
|
||||
</select>
|
||||
<input placeholder="Industrie (opțional)" value={form.industry} onChange={(e) => setForm((p) => ({ ...p, industry: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMut.mutate()} disabled={!form.name || addMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{addMut.isPending ? '…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input placeholder="Caută client sau organizație…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-4 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
{loadC ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-3xl mb-2">🤝</p>
|
||||
<p className="text-sm text-ink-faint">{search ? 'Niciun client găsit.' : 'Niciun client adăugat. Adaugă contacte cu tag-ul „client".'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{filtered.map((c) => {
|
||||
const rev = clientRevenue(c);
|
||||
const cts = clientContracts(c);
|
||||
const status = clientStatus(c);
|
||||
const initials = c.name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-4 p-4">
|
||||
<div className="h-10 w-10 shrink-0 rounded-full bg-primary/10 flex items-center justify-center font-semibold text-primary text-sm">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-ink truncate">{c.name}</p>
|
||||
<span className={`text-[10px] font-medium capitalize ${STATUS_COLORS[status] ?? 'text-ink-faint'}`}>{status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-faint truncate">{[c.role, c.organization].filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-0.5">
|
||||
{rev > 0 && <p className="text-sm font-semibold text-signal-ok">{rev.toLocaleString('ro-RO')} €</p>}
|
||||
{cts.length > 0 && <p className="text-[10px] text-ink-faint">{cts.length} contract{cts.length > 1 ? 'e' : ''}</p>}
|
||||
{c.email && (
|
||||
<a href={`mailto:${c.email}`} className="text-[10px] text-primary hover:underline block">{c.email}</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue