feat(CC-073): add Relationship Dashboard (contacts/orgs stats + consent + tags)
This commit is contained in:
parent
689e88a09f
commit
b4579e4049
1 changed files with 201 additions and 0 deletions
201
src/app/dashboard/relationships/page.tsx
Normal file
201
src/app/dashboard/relationships/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue