feat(CC-090): add Client Tracker page (contacts with client tag, revenue from contracts, status)

This commit is contained in:
admin-valentin 2026-08-02 18:00:41 +00:00
parent 7f0fe42a33
commit 66a4512d19

View 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>
);
}