From 203ab390a4feb597af1d387053d913857f09b01d Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 12:09:08 +0000 Subject: [PATCH] feat(CC-066): add Scenarios page (per-decision grouping, weighted EV, confirm/rule-out) --- src/app/dashboard/scenarios/page.tsx | 219 +++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/app/dashboard/scenarios/page.tsx diff --git a/src/app/dashboard/scenarios/page.tsx b/src/app/dashboard/scenarios/page.tsx new file mode 100644 index 0000000..5657020 --- /dev/null +++ b/src/app/dashboard/scenarios/page.tsx @@ -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 = { low: 0.2, medium: 0.5, high: 0.8 }; + +const PROB_META: Record = { + 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 = { + positive: { icon: '↑', cls: 'text-signal-ok' }, + negative: { icon: '↓', cls: 'text-signal-danger' }, + neutral: { icon: '→', cls: 'text-ink-faint' }, +}; + +const STATUS_META: Record = { + 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(`/v1/scenarios${selectedDecision ? `?decisionId=${selectedDecision}` : ''}`, { tenantId }), + enabled: Boolean(tenantId), + staleTime: 30_000, + }); + + const { data: decisions = [] } = useQuery({ + queryKey: ['decisions-list', tenantId], + queryFn: () => apiFetch('/v1/decisions?limit=50', { tenantId }), + enabled: Boolean(tenantId), + staleTime: 120_000, + }); + + const createMut = useMutation({ + mutationFn: (body: Record) => apiFetch('/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 ( +
+
+
+

Scenarii

+

+ {isLoading ? 'Se încarcă…' : `${scenarios.length} scenarii`} + {totalEV !== 0 && ` · EV ponderat: `} + {totalEV !== 0 && ( + 0 ? 'text-signal-ok font-medium' : 'text-signal-danger font-medium'}> + {totalEV > 0 ? '+' : ''}{(totalEV / 1000).toFixed(0)}k {currency} + + )} +

+
+
+ + +
+
+ + {/* Create form */} + {showCreate && ( +
+

Scenariu nou

+
+
+ 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" /> +
+ + + + 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" /> +
+ 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" /> +
+
+
+ + +
+
+ )} + + {/* Scenarios list */} + {!isLoading && scenarios.length === 0 ? ( +
+ Niciun scenariu. Adaugă primul scenariu. +
+ ) : ( +
+ {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 ( +
+ {dirMeta.icon} +
+
+ {s.title} + {probMeta.label} + {s.status !== 'hypothetical' && ( + {s.status.replace('_', ' ')} + )} +
+ {s.outcome &&

{s.outcome}

} + {money && ( +
+ {money} + {ev && {ev}} +
+ )} +
+
+ {s.status !== 'confirmed' && s.status !== 'ruled_out' && ( + <> + + + + )} +
+
+ ); + })} +
+ )} +
+ ); +}