feat(CC-073): add Interaction Timeline page (observations + briefs + decisions timeline)

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

View file

@ -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<string, { bg: string; text: string; border: string }> = {
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<Contact[]>('/v1/contacts?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: observations = [], isLoading: obsLoad } = useQuery({
queryKey: ['observations-tl', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
});
const { data: briefs = [], isLoading: briefLoad } = useQuery({
queryKey: ['briefs-tl', tenantId],
queryFn: () => apiFetch<ResearchBrief[]>('/v1/research-briefs?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: decisions = [], isLoading: decLoad } = useQuery({
queryKey: ['decisions-tl', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: outcomes = [], isLoading: outLoad } = useQuery({
queryKey: ['outcomes-tl', tenantId],
queryFn: () => apiFetch<OutcomeReview[]>('/v1/outcome-reviews?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const contactMap = useMemo(() => {
const m: Record<string, string> = {};
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<string, number> = { all: items.length };
for (const i of items) counts[i.type] = (counts[i.type] ?? 0) + 1;
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Timeline Interacțiuni</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${items.length} evenimente înregistrate`}
</p>
</div>
{/* Search */}
<input placeholder="Caută în timeline…" value={search}
onChange={(e) => 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 */}
<div className="flex flex-wrap gap-2">
{(['all', 'observation', 'brief', 'decision', 'outcome'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`rounded-full px-3 py-1 text-xs border transition-colors ${filter === f ? 'bg-primary/10 border-primary text-ink font-medium' : 'bg-card border-border text-ink-faint hover:border-primary/30'}`}>
{f === 'all' ? `Toate (${counts.all ?? 0})` :
f === 'observation' ? `📊 Observații (${counts.observation ?? 0})` :
f === 'brief' ? `📋 Briefs (${counts.brief ?? 0})` :
f === 'decision' ? `🧠 Decizii (${counts.decision ?? 0})` :
`🔍 Revizuiri (${counts.outcome ?? 0})`}
</button>
))}
</div>
{/* Timeline */}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-10 text-center space-y-2">
<p className="text-2xl">📅</p>
<p className="text-sm text-ink-faint">Niciun eveniment{search ? ' pentru această căutare' : ''}.</p>
</div>
) : (
<div className="relative space-y-2 pl-6">
{/* Vertical timeline line */}
<div className="absolute left-2 top-0 bottom-0 w-px bg-border/50" />
{filtered.slice(0, 100).map((item) => {
const cls = TYPE_CLS[item.type];
return (
<div key={item.id} className="relative">
{/* Dot */}
<div className={`absolute -left-4 top-3 w-3 h-3 rounded-full border-2 border-card ${cls.bg} ring-2 ring-border/30`} />
<div className={`card border-l-4 ${cls.border} px-4 py-3`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span>{item.icon}</span>
<p className="text-sm font-medium text-ink truncate">{item.title}</p>
</div>
{item.subtitle && (
<p className="text-[10px] text-ink-faint mt-0.5 ml-6">{item.subtitle}</p>
)}
</div>
<div className="text-right shrink-0 space-y-0.5">
<p className="text-[10px] text-ink-faint">{relTime(item.date)}</p>
{item.meta && (
<span className={`rounded-full px-1.5 py-0.5 text-[9px] font-medium ${cls.bg} ${cls.text}`}>
{item.meta}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
{filtered.length > 100 && (
<p className="text-xs text-center text-ink-faint pt-2">și alte {filtered.length - 100} evenimente</p>
)}
</div>
)}
</div>
);
}