feat(CC-073): add Relationship Dashboard (contacts/orgs stats + consent + tags)

This commit is contained in:
admin-valentin 2026-08-02 12:35:02 +00:00
parent 689e88a09f
commit b4579e4049

View file

@ -0,0 +1,201 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Contact {
id: string; fullName: string; email: string | null; phone: string | null;
role: string | null; tags: string[]; consentStatus: string; createdAt: string;
}
interface Organization {
id: string; name: string; industry: string | null; country: string | null;
employeeCount: number | null; tags: string[]; createdAt: string;
}
interface Observation {
id: string; subjectType: string; subjectId: string; metric: string;
value: string; observedAt: string | null; createdAt: string;
}
export default function RelationshipDashboard() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: contacts = [], isLoading: cLoad } = useQuery({
queryKey: ['contacts-rel', tenantId],
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: orgs = [], isLoading: oLoad } = useQuery({
queryKey: ['orgs-rel', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: observations = [] } = useQuery({
queryKey: ['observations-rel', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations?subjectType=contact', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const isLoading = cLoad || oLoad;
// Stats
const stats = useMemo(() => {
const consentBreakdown: Record<string, number> = {};
for (const c of contacts) {
consentBreakdown[c.consentStatus] = (consentBreakdown[c.consentStatus] ?? 0) + 1;
}
const tagFreq: Record<string, number> = {};
for (const c of contacts) {
for (const t of c.tags) tagFreq[t] = (tagFreq[t] ?? 0) + 1;
}
const topTags = Object.entries(tagFreq).sort(([,a],[,b]) => b - a).slice(0, 8);
const obsPerContact: Record<string, number> = {};
for (const o of observations) {
obsPerContact[o.subjectId] = (obsPerContact[o.subjectId] ?? 0) + 1;
}
const topContacts = contacts
.filter((c) => obsPerContact[c.id])
.sort((a, b) => (obsPerContact[b.id] ?? 0) - (obsPerContact[a.id] ?? 0))
.slice(0, 5);
const recentContacts = [...contacts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
const industryBreakdown: Record<string, number> = {};
for (const o of orgs) {
if (o.industry) industryBreakdown[o.industry] = (industryBreakdown[o.industry] ?? 0) + 1;
}
const topIndustries = Object.entries(industryBreakdown).sort(([,a],[,b]) => b - a).slice(0, 5);
return { consentBreakdown, topTags, topContacts, recentContacts, topIndustries, obsPerContact };
}, [contacts, orgs, observations]);
const CONSENT_CLS: Record<string, string> = {
granted: 'text-signal-ok', revoked: 'text-signal-danger', unknown: 'text-ink-faint',
};
return (
<div className="max-w-5xl space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Relationship Dashboard</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${contacts.length} contacte · ${orgs.length} organizații · ${observations.length} observații`}
</p>
</div>
<div className="flex gap-2">
<Link href="/dashboard/crm" className="rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50">
CRM
</Link>
<Link href="/dashboard/intelligence/people" className="rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50">
People Intelligence
</Link>
</div>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[
{ label: 'Contacte totale', value: contacts.length, sub: 'în baza de date', href: '/dashboard/crm' },
{ label: 'Organizații', value: orgs.length, sub: 'înregistrate', href: '/dashboard/crm' },
{ label: 'GDPR granted', value: stats.consentBreakdown['granted'] ?? 0, sub: `din ${contacts.length}`, href: '/dashboard/privacy/consents' },
{ label: 'Cu observații', value: Object.keys(stats.obsPerContact).length, sub: 'contacte monitorizate', href: '/dashboard/intelligence/people' },
].map((s) => (
<Link key={s.label} href={s.href}
className="card p-4 text-center hover:border-primary/40 transition-colors space-y-1">
<p className="font-display text-3xl font-bold text-ink">{s.value}</p>
<p className="text-xs font-medium text-ink">{s.label}</p>
<p className="text-[10px] text-ink-faint">{s.sub}</p>
</Link>
))}
</div>
<div className="grid gap-4 sm:grid-cols-3">
{/* Consent breakdown */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Consimțământ GDPR</p>
{Object.entries(stats.consentBreakdown).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<span className={`text-xs font-medium ${CONSENT_CLS[status] ?? 'text-ink-faint'}`}>{status}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-16">
<div className="h-full bg-current" style={{ width: `${Math.round((count / contacts.length) * 100)}%` }} />
</div>
<span className="text-xs font-mono w-8 text-right text-ink-faint">{count}</span>
</div>
</div>
))}
</div>
{/* Top tags */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Top taguri</p>
{stats.topTags.length === 0 ? (
<p className="text-xs text-ink-faint">Niciun tag.</p>
) : stats.topTags.map(([tag, count]) => (
<div key={tag} className="flex items-center justify-between">
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-ink">{tag}</span>
<span className="text-xs font-mono text-ink-faint">{count}</span>
</div>
))}
</div>
{/* Top industries (orgs) */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Industrii (organizații)</p>
{stats.topIndustries.length === 0 ? (
<p className="text-xs text-ink-faint">Nicio industrie.</p>
) : stats.topIndustries.map(([ind, count]) => (
<div key={ind} className="flex items-center justify-between">
<span className="text-xs text-ink truncate max-w-[140px]">{ind}</span>
<span className="text-xs font-mono text-ink-faint">{count}</span>
</div>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Most observed contacts */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Contacte cu cele mai multe observații</p>
{stats.topContacts.length === 0 ? (
<p className="text-xs text-ink-faint">Nicio observație înregistrată.</p>
) : stats.topContacts.map((c) => (
<div key={c.id} className="flex items-center justify-between py-1 border-b border-border/30 last:border-0">
<div>
<p className="text-xs font-medium text-ink">{c.fullName}</p>
{c.role && <p className="text-[10px] text-ink-faint">{c.role}</p>}
</div>
<span className="text-xs font-mono text-primary">{stats.obsPerContact[c.id]} obs</span>
</div>
))}
</div>
{/* Recent contacts */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Adăugate recent</p>
{stats.recentContacts.map((c) => (
<div key={c.id} className="flex items-center justify-between py-1 border-b border-border/30 last:border-0">
<div>
<p className="text-xs font-medium text-ink">{c.fullName}</p>
<div className="flex gap-2">
{c.role && <p className="text-[10px] text-ink-faint">{c.role}</p>}
<p className={`text-[10px] ${CONSENT_CLS[c.consentStatus] ?? 'text-ink-faint'}`}>{c.consentStatus}</p>
</div>
</div>
<span className="text-[10px] text-ink-faint">
{new Date(c.createdAt).toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })}
</span>
</div>
))}
</div>
</div>
</div>
);
}