From b4579e40490c75db1e5fdceb0054a84acd1e083b Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 12:35:02 +0000 Subject: [PATCH] feat(CC-073): add Relationship Dashboard (contacts/orgs stats + consent + tags) --- src/app/dashboard/relationships/page.tsx | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/app/dashboard/relationships/page.tsx diff --git a/src/app/dashboard/relationships/page.tsx b/src/app/dashboard/relationships/page.tsx new file mode 100644 index 0000000..df9aabf --- /dev/null +++ b/src/app/dashboard/relationships/page.tsx @@ -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('/v1/contacts?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: orgs = [], isLoading: oLoad } = useQuery({ + queryKey: ['orgs-rel', tenantId], + queryFn: () => apiFetch('/v1/organizations?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: observations = [] } = useQuery({ + queryKey: ['observations-rel', tenantId], + queryFn: () => apiFetch('/v1/observations?subjectType=contact', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const isLoading = cLoad || oLoad; + + // Stats + const stats = useMemo(() => { + const consentBreakdown: Record = {}; + for (const c of contacts) { + consentBreakdown[c.consentStatus] = (consentBreakdown[c.consentStatus] ?? 0) + 1; + } + + const tagFreq: Record = {}; + 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 = {}; + 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 = {}; + 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 = { + granted: 'text-signal-ok', revoked: 'text-signal-danger', unknown: 'text-ink-faint', + }; + + return ( +
+
+
+

Relationship Dashboard

+

+ {isLoading ? 'Se încarcă…' : `${contacts.length} contacte · ${orgs.length} organizații · ${observations.length} observații`} +

+
+
+ + CRM → + + + People Intelligence → + +
+
+ + {/* KPI row */} +
+ {[ + { 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) => ( + +

{s.value}

+

{s.label}

+

{s.sub}

+ + ))} +
+ +
+ {/* Consent breakdown */} +
+

Consimțământ GDPR

+ {Object.entries(stats.consentBreakdown).map(([status, count]) => ( +
+ {status} +
+
+
+
+ {count} +
+
+ ))} +
+ + {/* Top tags */} +
+

Top taguri

+ {stats.topTags.length === 0 ? ( +

Niciun tag.

+ ) : stats.topTags.map(([tag, count]) => ( +
+ {tag} + {count} +
+ ))} +
+ + {/* Top industries (orgs) */} +
+

Industrii (organizații)

+ {stats.topIndustries.length === 0 ? ( +

Nicio industrie.

+ ) : stats.topIndustries.map(([ind, count]) => ( +
+ {ind} + {count} +
+ ))} +
+
+ +
+ {/* Most observed contacts */} +
+

Contacte cu cele mai multe observații

+ {stats.topContacts.length === 0 ? ( +

Nicio observație înregistrată.

+ ) : stats.topContacts.map((c) => ( +
+
+

{c.fullName}

+ {c.role &&

{c.role}

} +
+ {stats.obsPerContact[c.id]} obs +
+ ))} +
+ + {/* Recent contacts */} +
+

Adăugate recent

+ {stats.recentContacts.map((c) => ( +
+
+

{c.fullName}

+
+ {c.role &&

{c.role}

} +

{c.consentStatus}

+
+
+ + {new Date(c.createdAt).toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })} + +
+ ))} +
+
+
+ ); +}