feat(CC-070): add Insights page (observations log + research briefs with tabs)
This commit is contained in:
parent
6bb902cb40
commit
f05c880d77
1 changed files with 216 additions and 0 deletions
216
src/app/dashboard/intelligence/insights/page.tsx
Normal file
216
src/app/dashboard/intelligence/insights/page.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
'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 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;
|
||||
}
|
||||
|
||||
const SUBJECT_TYPES = ['all','organization','goal','decision','contact','project','market','custom'];
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [tab, setTab] = useState<'observations' | 'briefs'>('observations');
|
||||
const [subjectFilter, setSubjectFilter] = useState('all');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ subjectType: 'organization', subjectId: '', metric: '', value: '', unit: '', source: 'analyst', confidence: '0.8' });
|
||||
|
||||
const { data: observations = [], isLoading: obsLoading } = useQuery({
|
||||
queryKey: ['observations', tenantId, subjectFilter],
|
||||
queryFn: () => {
|
||||
const params = subjectFilter !== 'all' ? `?subjectType=${subjectFilter}` : '';
|
||||
return apiFetch<Observation[]>(`/v1/observations${params}`, { tenantId });
|
||||
},
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: briefs = [], isLoading: briefsLoading } = useQuery({
|
||||
queryKey: ['briefs', tenantId],
|
||||
queryFn: () => apiFetch<ResearchBrief[]>('/v1/research-briefs?limit=100', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, string>) =>
|
||||
apiFetch<Observation>('/v1/observations', { tenantId, method: 'POST', body }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['observations', tenantId] }); setShowCreate(false); setForm({ subjectType: 'organization', subjectId: '', metric: '', value: '', unit: '', source: 'analyst', confidence: '0.8' }); },
|
||||
});
|
||||
|
||||
// Group observations by metric
|
||||
const byMetric: Record<string, Observation[]> = {};
|
||||
for (const o of observations) {
|
||||
byMetric[o.metric] = [...(byMetric[o.metric] ?? []), o];
|
||||
}
|
||||
|
||||
const topMetrics = Object.entries(byMetric)
|
||||
.sort(([, a], [, b]) => b.length - a.length)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Insights</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{observations.length} observații · {briefs.length} research briefs
|
||||
</p>
|
||||
</div>
|
||||
{tab === 'observations' && (
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Observație
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border/50">
|
||||
{[['observations', 'Observații'], ['briefs', 'Research Briefs']].map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key as typeof tab)}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'observations' && (
|
||||
<>
|
||||
{/* Top metrics overview */}
|
||||
{topMetrics.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{topMetrics.map(([metric, items]) => (
|
||||
<button key={metric} onClick={() => setSubjectFilter('all')}
|
||||
className="card p-3 text-center hover:border-primary/40 transition-colors">
|
||||
<p className="font-display text-xl font-bold text-ink">{items.length}</p>
|
||||
<p className="text-[10px] text-ink-faint mt-0.5 truncate">{metric}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subject type filter */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{SUBJECT_TYPES.map((t) => (
|
||||
<button key={t} onClick={() => setSubjectFilter(t)}
|
||||
className={`rounded-full px-3 py-1 text-xs border transition-colors ${subjectFilter === t ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint hover:border-primary/30'}`}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-ink">Observație nouă</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<select value={form.subjectType} onChange={(e) => setForm({ ...form, subjectType: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{['organization','goal','decision','contact','project','market','custom'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="Subject ID *" value={form.subjectId}
|
||||
onChange={(e) => setForm({ ...form, subjectId: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Metric *" value={form.metric}
|
||||
onChange={(e) => setForm({ ...form, metric: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Valoare *" value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Unitate (ex: %, EUR, index)" value={form.unit}
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-ink-faint shrink-0">Confidence</label>
|
||||
<input type="range" min="0" max="1" step="0.1" value={form.confidence}
|
||||
onChange={(e) => setForm({ ...form, confidence: e.target.value })}
|
||||
className="flex-1" />
|
||||
<span className="text-xs font-mono w-8 text-right">{Math.round(parseFloat(form.confidence)*100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate({ subjectType: form.subjectType, subjectId: form.subjectId, metric: form.metric, value: form.value, unit: form.unit, source: form.source, confidence: form.confidence })}
|
||||
disabled={!form.subjectId || !form.metric || !form.value || createMut.isPending}
|
||||
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se creează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Observations list */}
|
||||
{obsLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Se încarcă…</div>
|
||||
) : observations.length === 0 ? (
|
||||
<div className="card p-10 text-center space-y-2">
|
||||
<p className="text-2xl">📊</p>
|
||||
<p className="text-sm text-ink-faint">Nicio observație înregistrată.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{observations.slice(0, 50).map((o) => (
|
||||
<div key={o.id} className="flex items-start gap-3 p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold text-ink">{o.metric}</span>
|
||||
<span className="text-sm font-mono font-bold text-ink">{o.value}{o.unit ? ` ${o.unit}` : ''}</span>
|
||||
{o.confidence != null && (
|
||||
<span className="text-[10px] text-ink-faint">{Math.round(o.confidence*100)}% conf</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-faint mt-0.5">
|
||||
{o.subjectType}:{o.subjectId.slice(0, 12)}… · {o.source}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-faint shrink-0">
|
||||
{new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{observations.length > 50 && (
|
||||
<p className="text-xs text-center text-ink-faint p-3">și alte {observations.length - 50} observații…</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'briefs' && (
|
||||
<>
|
||||
{briefsLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Se încarcă…</div>
|
||||
) : briefs.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 research brief.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{briefs.map((b) => (
|
||||
<div key={b.id} className="p-4 space-y-1">
|
||||
<p className="text-sm font-semibold text-ink">{b.title}</p>
|
||||
{b.organizationName && <p className="text-xs text-ink-faint">🏢 {b.organizationName}</p>}
|
||||
{b.summary && <p className="text-xs text-ink-faint line-clamp-2">{b.summary}</p>}
|
||||
<p className="text-[10px] text-ink-faint/60">{new Date(b.createdAt).toLocaleDateString('ro-RO')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue