feat(CC-066): add Scenarios page (per-decision grouping, weighted EV, confirm/rule-out)
This commit is contained in:
parent
d7d8c91ad5
commit
203ab390a4
1 changed files with 219 additions and 0 deletions
219
src/app/dashboard/scenarios/page.tsx
Normal file
219
src/app/dashboard/scenarios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue