feat(CC-066): add Scenarios page (per-decision grouping, weighted EV, confirm/rule-out)

This commit is contained in:
admin-valentin 2026-08-02 12:09:08 +00:00
parent d7d8c91ad5
commit 203ab390a4

View file

@ -0,0 +1,219 @@
'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 Decision { id: string; context: string; selectedOption: string | null; }
interface Scenario {
id: string; title: string; description: string | null;
decisionId: string | null; probability: string;
outcome: string | null; financialImpactMinorUnits: string | null;
financialImpactCurrency: string; impactDirection: string;
status: string; createdAt: string;
}
const PROB_WEIGHT: Record<string, number> = { low: 0.2, medium: 0.5, high: 0.8 };
const PROB_META: Record<string, { label: string; cls: string }> = {
low: { label: 'Redusă', cls: 'text-sky-500 bg-sky-500/10' },
medium: { label: 'Medie', cls: 'text-signal-warn bg-signal-warn/10' },
high: { label: 'Ridicată',cls: 'text-signal-danger bg-signal-danger/10' },
};
const DIR_META: Record<string, { icon: string; cls: string }> = {
positive: { icon: '↑', cls: 'text-signal-ok' },
negative: { icon: '↓', cls: 'text-signal-danger' },
neutral: { icon: '→', cls: 'text-ink-faint' },
};
const STATUS_META: Record<string, string> = {
hypothetical: 'text-ink-faint', likely: 'text-signal-warn',
confirmed: 'text-signal-ok', ruled_out: 'text-ink-faint line-through',
};
function fmtMoney(minorUnits: string | null, currency: string) {
if (!minorUnits) return null;
const val = parseFloat(minorUnits) / 100;
try { return new Intl.NumberFormat('ro-RO', { style: 'currency', currency, maximumFractionDigits: 0 }).format(val); }
catch { return `${currency} ${val.toFixed(0)}`; }
}
export default function ScenariosPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [selectedDecision, setSelectedDecision] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
title: '', probability: 'medium', impactDirection: 'neutral',
financialImpactMinorUnits: '', decisionId: '', outcome: '',
});
const { data: scenarios = [], isLoading } = useQuery({
queryKey: ['scenarios', tenantId, selectedDecision],
queryFn: () => apiFetch<Scenario[]>(`/v1/scenarios${selectedDecision ? `?decisionId=${selectedDecision}` : ''}`, { tenantId }),
enabled: Boolean(tenantId),
staleTime: 30_000,
});
const { data: decisions = [] } = useQuery({
queryKey: ['decisions-list', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=50', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const createMut = useMutation({
mutationFn: (body: Record<string, string>) => apiFetch<Scenario>('/v1/scenarios', { tenantId, method: 'POST', body }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['scenarios', tenantId] }); setShowCreate(false); setForm({ title: '', probability: 'medium', impactDirection: 'neutral', financialImpactMinorUnits: '', decisionId: '', outcome: '' }); },
});
const patchMut = useMutation({
mutationFn: ({ id, ...body }: { id: string; status: string }) => apiFetch(`/v1/scenarios/${id}`, { tenantId, method: 'PATCH', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scenarios', tenantId] }),
});
// Weighted expected value (across all active scenarios)
const totalEV = scenarios.filter(s => s.status !== 'ruled_out').reduce((sum, s) => {
const impact = parseFloat(s.financialImpactMinorUnits ?? '0') / 100;
const w = PROB_WEIGHT[s.probability] ?? 0.5;
const dir = s.impactDirection === 'negative' ? -1 : 1;
return sum + impact * w * dir;
}, 0);
const currency = scenarios[0]?.financialImpactCurrency ?? 'RON';
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Scenarii</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${scenarios.length} scenarii`}
{totalEV !== 0 && ` · EV ponderat: `}
{totalEV !== 0 && (
<span className={totalEV > 0 ? 'text-signal-ok font-medium' : 'text-signal-danger font-medium'}>
{totalEV > 0 ? '+' : ''}{(totalEV / 1000).toFixed(0)}k {currency}
</span>
)}
</p>
</div>
<div className="flex gap-2">
<select value={selectedDecision} onChange={(e) => setSelectedDecision(e.target.value)}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none">
<option value="">Toate deciziile</option>
{decisions.map((d) => (
<option key={d.id} value={d.id}>{(d.context ?? '').slice(0, 40)}</option>
))}
</select>
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
+ Scenariu
</button>
</div>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4">
<h2 className="text-sm font-semibold text-ink">Scenariu nou</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<input placeholder="Titlu scenariu *"
value={form.title} onChange={(e) => setForm({ ...form, title: 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" />
</div>
<select value={form.decisionId} onChange={(e) => setForm({ ...form, decisionId: 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">
<option value=""> Fără decizie </option>
{decisions.map((d) => <option key={d.id} value={d.id}>{(d.context ?? '').slice(0, 40)}</option>)}
</select>
<select value={form.probability} onChange={(e) => setForm({ ...form, probability: 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">
{['low','medium','high'].map((p) => <option key={p} value={p}>{PROB_META[p]?.label ?? p}</option>)}
</select>
<select value={form.impactDirection} onChange={(e) => setForm({ ...form, impactDirection: 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">
<option value="positive"> Pozitiv</option>
<option value="negative"> Negativ</option>
<option value="neutral"> Neutru</option>
</select>
<input placeholder="Impact financiar (RON, ex: 50000)" type="number"
value={form.financialImpactMinorUnits}
onChange={(e) => setForm({ ...form, financialImpactMinorUnits: String(Math.round(parseFloat(e.target.value || '0') * 100)) })}
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="sm:col-span-2">
<input placeholder="Outcome așteptat (opțional)"
value={form.outcome} onChange={(e) => setForm({ ...form, outcome: 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" />
</div>
</div>
<div className="flex gap-2">
<button onClick={() => createMut.mutate({ title: form.title, probability: form.probability, impactDirection: form.impactDirection, financialImpactMinorUnits: form.financialImpactMinorUnits || '0', decisionId: form.decisionId, outcome: form.outcome })}
disabled={!form.title || createMut.isPending}
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
{createMut.isPending ? 'Se creează…' : 'Crează'}
</button>
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
</div>
</div>
)}
{/* Scenarios list */}
{!isLoading && scenarios.length === 0 ? (
<div className="card p-12 text-center text-sm text-ink-faint">
Niciun scenariu. Adaugă primul scenariu.
</div>
) : (
<div className="card divide-y divide-border/50">
{scenarios.map((s) => {
const probMeta = PROB_META[s.probability] ?? PROB_META.medium;
const dirMeta = DIR_META[s.impactDirection] ?? DIR_META.neutral;
const money = fmtMoney(s.financialImpactMinorUnits, s.financialImpactCurrency);
const ev = money ? `EV: ${dirMeta.icon}${fmtMoney(
String(Math.round(parseFloat(s.financialImpactMinorUnits ?? '0') * (PROB_WEIGHT[s.probability] ?? 0.5))),
s.financialImpactCurrency
)}` : null;
return (
<div key={s.id} className={`p-4 flex items-start gap-4 ${STATUS_META[s.status] ?? ''}`}>
<span className={`text-lg font-bold shrink-0 ${dirMeta.cls}`}>{dirMeta.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-ink">{s.title}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${probMeta.cls}`}>{probMeta.label}</span>
{s.status !== 'hypothetical' && (
<span className="text-[10px] text-ink-faint capitalize">{s.status.replace('_', ' ')}</span>
)}
</div>
{s.outcome && <p className="text-xs text-ink-faint mt-1 line-clamp-2">{s.outcome}</p>}
{money && (
<div className="flex gap-3 mt-1">
<span className={`text-xs font-mono font-medium ${dirMeta.cls}`}>{money}</span>
{ev && <span className="text-xs text-ink-faint font-mono">{ev}</span>}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
{s.status !== 'confirmed' && s.status !== 'ruled_out' && (
<>
<button onClick={() => patchMut.mutate({ id: s.id, status: 'confirmed' })}
className="rounded border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5 transition-colors">
Confirmat
</button>
<button onClick={() => patchMut.mutate({ id: s.id, status: 'ruled_out' })}
className="rounded border px-2 py-1 text-[10px] text-ink-faint hover:bg-muted transition-colors">
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}