diff --git a/src/app/dashboard/clients/page.tsx b/src/app/dashboard/clients/page.tsx new file mode 100644 index 0000000..3f6d0c0 --- /dev/null +++ b/src/app/dashboard/clients/page.tsx @@ -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 = { + 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('/v1/contacts?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: contracts = [] } = useQuery({ + queryKey: ['clients-contracts', tenantId], + queryFn: () => apiFetch('/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 ( +
+
+
+

Client Tracker

+

+ {clients.length} clienți · Total venituri: {totalRevenue.toLocaleString('ro-RO')} € +

+
+ +
+ + {showAdd && ( +
+

Client nou

+
+ 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" /> + 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" /> + 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" /> + 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" /> + + 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" /> +
+
+ + +
+
+ )} + + 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 ? ( +
Se încarcă…
+ ) : filtered.length === 0 ? ( +
+

🤝

+

{search ? 'Niciun client găsit.' : 'Niciun client adăugat. Adaugă contacte cu tag-ul „client".'}

+
+ ) : ( +
+ {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 ( +
+
+ {initials} +
+
+
+

{c.name}

+ {status} +
+

{[c.role, c.organization].filter(Boolean).join(' · ')}

+
+
+ {rev > 0 &&

{rev.toLocaleString('ro-RO')} €

} + {cts.length > 0 &&

{cts.length} contract{cts.length > 1 ? 'e' : ''}

} + {c.email && ( + {c.email} + )} +
+
+ ); + })} +
+ )} +
+ ); +}