feat(CC-073): add Interaction Timeline page (observations + briefs + decisions timeline)
This commit is contained in:
parent
4435186ef6
commit
689e88a09f
1 changed files with 209 additions and 0 deletions
209
src/app/dashboard/interactions/page.tsx
Normal file
209
src/app/dashboard/interactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue