feat(CC-070): add People Intelligence page (contacts + observations + research briefs)
This commit is contained in:
parent
c7deff3259
commit
6bb902cb40
1 changed files with 209 additions and 0 deletions
209
src/app/dashboard/intelligence/people/page.tsx
Normal file
209
src/app/dashboard/intelligence/people/page.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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;
|
||||
notes: string | null; createdAt: string;
|
||||
}
|
||||
interface Observation {
|
||||
id: string; subjectType: string; subjectId: string; metric: string;
|
||||
value: string; unit: string | null; source: string; confidence: number | null;
|
||||
observedAt: string | null; createdAt: string;
|
||||
}
|
||||
interface ResearchBrief {
|
||||
id: string; title: string; organizationName: string | null; summary: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
const CONSENT_CLS: Record<string, string> = {
|
||||
granted: 'text-signal-ok', revoked: 'text-signal-danger', unknown: 'text-ink-faint',
|
||||
};
|
||||
|
||||
export default function PeopleIntelligencePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Contact | null>(null);
|
||||
const [obsForm, setObsForm] = useState({ metric: '', value: '', unit: '', source: 'manual' });
|
||||
|
||||
const { data: contacts = [], isLoading } = useQuery({
|
||||
queryKey: ['contacts-intel', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: observations = [] } = useQuery({
|
||||
queryKey: ['obs-contact', tenantId, selected?.id],
|
||||
queryFn: () => apiFetch<Observation[]>(`/v1/observations?subjectType=contact&subjectId=${selected!.id}`, { tenantId }),
|
||||
enabled: Boolean(tenantId) && Boolean(selected),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: briefs = [] } = useQuery({
|
||||
queryKey: ['briefs-intel', tenantId],
|
||||
queryFn: () => apiFetch<ResearchBrief[]>('/v1/research-briefs?limit=50', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||
});
|
||||
|
||||
const createObsMut = useMutation({
|
||||
mutationFn: (body: Record<string, string>) =>
|
||||
apiFetch<Observation>('/v1/observations', { tenantId, method: 'POST', body }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['obs-contact'] }); setObsForm({ metric: '', value: '', unit: '', source: 'manual' }); },
|
||||
});
|
||||
|
||||
const filtered = contacts.filter((c) =>
|
||||
!search || c.fullName.toLowerCase().includes(search.toLowerCase())
|
||||
|| c.email?.toLowerCase().includes(search.toLowerCase())
|
||||
|| c.role?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Intelligence Persoane</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${contacts.length} contacte · ${briefs.length} research briefs disponibile`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* Contact list */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<input placeholder="Caută persoană…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Se încarcă…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Niciun contact.</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50 max-h-[500px] overflow-y-auto">
|
||||
{filtered.map((c) => (
|
||||
<button key={c.id} onClick={() => setSelected(c)}
|
||||
className={`w-full text-left p-3 transition-colors hover:bg-muted/50 ${selected?.id === c.id ? 'bg-primary/5' : ''}`}>
|
||||
<p className="text-sm font-medium text-ink">{c.fullName}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{c.role && <span className="text-[10px] text-ink-faint">{c.role}</span>}
|
||||
<span className={`text-[10px] ${CONSENT_CLS[c.consentStatus] ?? 'text-ink-faint'}`}>
|
||||
{c.consentStatus === 'granted' ? '✓ GDPR' : c.consentStatus === 'revoked' ? '✗ GDPR' : '? GDPR'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact detail + observations */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{!selected ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">👤</p>
|
||||
<p className="text-sm text-ink-faint">Selectează un contact pentru a vedea intelligence-ul.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Contact header */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-ink">{selected.fullName}</h2>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{selected.role && <span className="text-xs text-ink-faint">{selected.role}</span>}
|
||||
{selected.email && <span className="text-xs text-ink-faint">{selected.email}</span>}
|
||||
{selected.phone && <span className="text-xs text-ink-faint">{selected.phone}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${CONSENT_CLS[selected.consentStatus] ?? 'text-ink-faint'}`}>
|
||||
{selected.consentStatus}
|
||||
</span>
|
||||
</div>
|
||||
{selected.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-ink">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.notes && <p className="text-xs text-ink-faint border-t border-border/50 pt-3">{selected.notes}</p>}
|
||||
</div>
|
||||
|
||||
{/* Linked research briefs */}
|
||||
{briefs.length > 0 && (
|
||||
<div className="card p-4 space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint">Research briefs relevante</p>
|
||||
{briefs
|
||||
.filter((b) => b.title.toLowerCase().includes(selected.fullName.toLowerCase().split(' ')[0]) || (b.organizationName ?? '').toLowerCase().includes(selected.fullName.toLowerCase().split(' ')[0]))
|
||||
.slice(0, 3)
|
||||
.map((b) => (
|
||||
<div key={b.id} className="py-1 border-t border-border/30">
|
||||
<p className="text-xs font-medium text-ink">{b.title}</p>
|
||||
{b.summary && <p className="text-[10px] text-ink-faint mt-0.5 line-clamp-1">{b.summary}</p>}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{briefs.filter((b) => b.title.toLowerCase().includes(selected.fullName.toLowerCase().split(' ')[0])).length === 0 && (
|
||||
<p className="text-xs text-ink-faint">Niciun brief direct.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Observations */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint">Observații ({observations.length})</p>
|
||||
|
||||
{/* Add observation form */}
|
||||
<div className="space-y-2 pb-3 border-b border-border/50">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input placeholder="Metric *" value={obsForm.metric}
|
||||
onChange={(e) => setObsForm({ ...obsForm, metric: e.target.value })}
|
||||
className="rounded-lg border bg-card px-2 py-1.5 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
<input placeholder="Valoare *" value={obsForm.value}
|
||||
onChange={(e) => setObsForm({ ...obsForm, value: e.target.value })}
|
||||
className="rounded-lg border bg-card px-2 py-1.5 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
<input placeholder="Unitate" value={obsForm.unit}
|
||||
onChange={(e) => setObsForm({ ...obsForm, unit: e.target.value })}
|
||||
className="rounded-lg border bg-card px-2 py-1.5 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createObsMut.mutate({ subjectType: 'contact', subjectId: selected.id, metric: obsForm.metric, value: obsForm.value, unit: obsForm.unit, source: obsForm.source })}
|
||||
disabled={!obsForm.metric || !obsForm.value || createObsMut.isPending}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs text-bronze-deep hover:bg-primary/5 disabled:opacity-50">
|
||||
+ Adaugă observație
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{observations.length === 0 ? (
|
||||
<p className="text-xs text-ink-faint text-center py-2">Nicio observație.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{observations.map((o) => (
|
||||
<div key={o.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-ink">{o.metric}: </span>
|
||||
<span className="text-xs text-ink">{o.value}{o.unit ? ` ${o.unit}` : ''}</span>
|
||||
{o.confidence != null && (
|
||||
<span className="text-[10px] text-ink-faint ml-1">{Math.round(o.confidence * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-faint shrink-0">
|
||||
{new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue