diff --git a/src/app/dashboard/interactions/page.tsx b/src/app/dashboard/interactions/page.tsx new file mode 100644 index 0000000..ecb6c28 --- /dev/null +++ b/src/app/dashboard/interactions/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Contact { + id: string; fullName: string; role: string | null; +} +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; briefType: string | null; createdAt: string; +} +interface Decision { + id: string; title: string; status: string; decidedAt: string | null; createdAt: string; +} +interface OutcomeReview { + id: string; title: string; wasSuccessful: boolean | null; rating: number | null; + createdAt: string; decisionId: string | null; +} + +type TimelineItem = { + id: string; + type: 'observation' | 'brief' | 'decision' | 'outcome'; + date: Date; + title: string; + subtitle?: string; + meta?: string; + icon: string; +}; + +const TYPE_CLS: Record = { + observation: { bg: 'bg-blue-50 dark:bg-blue-900/20', text: 'text-blue-600 dark:text-blue-400', border: 'border-l-blue-400' }, + brief: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', border: 'border-l-violet-400' }, + decision: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', border: 'border-l-amber-400' }, + outcome: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-l-emerald-400' }, +}; + +function relTime(date: Date) { + const days = Math.floor((Date.now() - date.getTime()) / 86400_000); + if (days === 0) return 'azi'; + if (days === 1) return 'ieri'; + if (days < 7) return `${days}z în urmă`; + if (days < 30) return `${Math.floor(days/7)}s în urmă`; + if (days < 365) return `${Math.floor(days/30)}l în urmă`; + return `${Math.floor(days/365)}a în urmă`; +} + +export default function InteractionTimelinePage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const [filter, setFilter] = useState<'all' | 'observation' | 'brief' | 'decision' | 'outcome'>('all'); + const [search, setSearch] = useState(''); + + const { data: contacts = [] } = useQuery({ + queryKey: ['contacts-tl', tenantId], + queryFn: () => apiFetch('/v1/contacts?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: observations = [], isLoading: obsLoad } = useQuery({ + queryKey: ['observations-tl', tenantId], + queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 30_000, + }); + const { data: briefs = [], isLoading: briefLoad } = useQuery({ + queryKey: ['briefs-tl', tenantId], + queryFn: () => apiFetch('/v1/research-briefs?limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: decisions = [], isLoading: decLoad } = useQuery({ + queryKey: ['decisions-tl', tenantId], + queryFn: () => apiFetch('/v1/decisions?limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: outcomes = [], isLoading: outLoad } = useQuery({ + queryKey: ['outcomes-tl', tenantId], + queryFn: () => apiFetch('/v1/outcome-reviews?limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const contactMap = useMemo(() => { + const m: Record = {}; + for (const c of contacts) m[c.id] = c.fullName; + return m; + }, [contacts]); + + const items: TimelineItem[] = useMemo(() => { + const all: TimelineItem[] = []; + for (const o of observations) { + const name = o.subjectType === 'contact' ? contactMap[o.subjectId] : o.subjectId.slice(0, 12) + '…'; + all.push({ id: o.id, type: 'observation', date: new Date(o.observedAt ?? o.createdAt), + title: `${o.metric}: ${o.value}${o.unit ? ` ${o.unit}` : ''}`, + subtitle: name, meta: o.source, icon: '📊' }); + } + for (const b of briefs) { + all.push({ id: b.id, type: 'brief', date: new Date(b.createdAt), + title: b.title, subtitle: b.organizationName ?? undefined, + meta: b.briefType ?? undefined, icon: '📋' }); + } + for (const d of decisions) { + all.push({ id: d.id, type: 'decision', date: new Date(d.decidedAt ?? d.createdAt), + title: d.title, meta: d.status, icon: '🧠' }); + } + for (const o of outcomes) { + all.push({ id: o.id, type: 'outcome', date: new Date(o.createdAt), + title: o.title, + meta: o.wasSuccessful === true ? '✅ succes' : o.wasSuccessful === false ? '❌ eșec' : '—', + icon: '🔍' }); + } + return all.sort((a, b) => b.date.getTime() - a.date.getTime()); + }, [observations, briefs, decisions, outcomes, contactMap]); + + const filtered = items.filter((i) => { + if (filter !== 'all' && i.type !== filter) return false; + if (search && !i.title.toLowerCase().includes(search.toLowerCase()) + && !(i.subtitle ?? '').toLowerCase().includes(search.toLowerCase())) return false; + return true; + }); + + const isLoading = obsLoad || briefLoad || decLoad || outLoad; + + const counts: Record = { all: items.length }; + for (const i of items) counts[i.type] = (counts[i.type] ?? 0) + 1; + + return ( +
+
+

Timeline Interacțiuni

+

+ {isLoading ? 'Se încarcă…' : `${items.length} evenimente înregistrate`} +

+
+ + {/* Search */} + setSearch(e.target.value)} + className="w-full rounded-lg border bg-card px-4 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> + + {/* Filter chips */} +
+ {(['all', 'observation', 'brief', 'decision', 'outcome'] as const).map((f) => ( + + ))} +
+ + {/* Timeline */} + {isLoading ? ( +
Se încarcă…
+ ) : filtered.length === 0 ? ( +
+

📅

+

Niciun eveniment{search ? ' pentru această căutare' : ''}.

+
+ ) : ( +
+ {/* Vertical timeline line */} +
+ + {filtered.slice(0, 100).map((item) => { + const cls = TYPE_CLS[item.type]; + return ( +
+ {/* Dot */} +
+
+
+
+
+ {item.icon} +

{item.title}

+
+ {item.subtitle && ( +

{item.subtitle}

+ )} +
+
+

{relTime(item.date)}

+ {item.meta && ( + + {item.meta} + + )} +
+
+
+
+ ); + })} + {filtered.length > 100 && ( +

și alte {filtered.length - 100} evenimente…

+ )} +
+ )} +
+ ); +}