feat(CC-068): add Decision Reports page (lifecycle matrix: scenarios+plans+outcomes+EV)

This commit is contained in:
admin-valentin 2026-08-02 12:18:09 +00:00
parent 488dddea60
commit afc73c8dc4

View file

@ -0,0 +1,193 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface Decision {
id: string; context: string; selectedOption: string | null;
status: string; createdAt: string; horizon: string | null;
}
interface Scenario { id: string; decisionId: string | null; title: string; probability: string; status: string; financialImpactMinorUnits: string | null; financialImpactCurrency: string; impactDirection: string; }
interface ActionPlan { id: string; decisionId: string | null; title: string; status: string; priority: string; dueDate: string | null; }
interface OutcomeReview { id: string; decisionId: string | null; title: string; wasSuccessful: boolean | null; rating: number | null; createdAt: string; }
interface Assumption { id: string; decisionId: string | null; statement: string; status: string; confidence: string; }
const PROB_WEIGHT: Record<string, number> = { low: 0.2, medium: 0.5, high: 0.8 };
function fmtMoney(v: string | null, currency: string) {
if (!v) return null;
return new Intl.NumberFormat('ro-RO', { style: 'currency', currency, maximumFractionDigits: 0 }).format(parseFloat(v) / 100);
}
function LifecycleDot({ count, cls }: { count: number; cls: string }) {
if (count === 0) return <span className="text-[10px] text-ink-faint/30">0</span>;
return <span className={`text-[10px] font-bold ${cls}`}>{count}</span>;
}
export default function DecisionReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: decisions = [], isLoading: dl } = useQuery({
queryKey: ['dec-r', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: scenarios = [] } = useQuery({
queryKey: ['scen-r', tenantId],
queryFn: () => apiFetch<Scenario[]>('/v1/scenarios', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: actionPlans = [] } = useQuery({
queryKey: ['ap-r', tenantId],
queryFn: () => apiFetch<ActionPlan[]>('/v1/action-plans', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: reviews = [] } = useQuery({
queryKey: ['or-r', tenantId],
queryFn: () => apiFetch<OutcomeReview[]>('/v1/outcome-reviews', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: assumptions = [] } = useQuery({
queryKey: ['ass-r', tenantId],
queryFn: () => apiFetch<Assumption[]>('/v1/assumptions', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const enrich = decisions.map((d) => ({
...d,
scenarios: scenarios.filter((s) => s.decisionId === d.id),
plans: actionPlans.filter((a) => a.decisionId === d.id),
reviews: reviews.filter((r) => r.decisionId === d.id),
assumptions: assumptions.filter((a) => a.decisionId === d.id),
}));
const withReviews = enrich.filter((d) => d.reviews.length > 0);
const avgRating = withReviews.reduce((s, d) => s + (d.reviews[0]?.rating ?? 0), 0) / (withReviews.length || 1);
const successRate = withReviews.length > 0
? Math.round(withReviews.filter((d) => d.reviews[0]?.wasSuccessful).length / withReviews.length * 100)
: null;
const totalEV = scenarios
.filter((s) => s.status !== 'ruled_out')
.reduce((sum, s) => {
const v = parseFloat(s.financialImpactMinorUnits ?? '0') / 100;
return sum + v * (PROB_WEIGHT[s.probability] ?? 0.5) * (s.impactDirection === 'negative' ? -1 : 1);
}, 0);
return (
<div className="max-w-5xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Raport Decizii</h1>
<p className="text-sm text-ink-faint mt-1">
{dl ? 'Se încarcă…' : `${decisions.length} decizii · ${scenarios.length} scenarii · ${actionPlans.length} planuri de acțiune`}
</p>
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="card p-4 text-center">
<p className="font-display text-3xl font-bold text-ink">{decisions.length}</p>
<p className="text-xs text-ink-faint mt-1">Decizii totale</p>
</div>
<div className="card p-4 text-center">
<p className={`font-display text-3xl font-bold ${successRate !== null && successRate >= 70 ? 'text-signal-ok' : 'text-signal-warn'}`}>
{successRate !== null ? `${successRate}%` : '—'}
</p>
<p className="text-xs text-ink-faint mt-1">Rată succes</p>
</div>
<div className="card p-4 text-center">
<p className={`font-display text-3xl font-bold ${avgRating >= 7 ? 'text-signal-ok' : avgRating >= 5 ? 'text-signal-warn' : 'text-signal-danger'}`}>
{withReviews.length > 0 ? `${avgRating.toFixed(1)}` : '—'}
</p>
<p className="text-xs text-ink-faint mt-1">Rating mediu /10</p>
</div>
<div className="card p-4 text-center">
<p className={`font-display text-3xl font-bold ${totalEV >= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}>
{totalEV !== 0 ? `${totalEV > 0 ? '+' : ''}${(totalEV/1000).toFixed(0)}k` : '—'}
</p>
<p className="text-xs text-ink-faint mt-1">EV ponderat (RON)</p>
</div>
</div>
{/* Decision lifecycle table */}
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
<th className="text-left text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">Decizie</th>
<th className="text-center text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">Scenarii</th>
<th className="text-center text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">Planuri</th>
<th className="text-center text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">Asumpții</th>
<th className="text-center text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">Revizuiri</th>
<th className="text-right text-[10px] font-semibold uppercase tracking-wider text-ink-faint p-3">EV</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{dl ? (
<tr><td colSpan={6} className="text-center text-ink-faint py-8 text-xs">Se încarcă</td></tr>
) : enrich.length === 0 ? (
<tr><td colSpan={6} className="text-center text-ink-faint py-8 text-xs">Nicio decizie.</td></tr>
) : enrich.map((d) => {
const ev = d.scenarios
.filter((s) => s.status !== 'ruled_out')
.reduce((sum, s) => {
const v = parseFloat(s.financialImpactMinorUnits ?? '0') / 100;
return sum + v * (PROB_WEIGHT[s.probability] ?? 0.5) * (s.impactDirection === 'negative' ? -1 : 1);
}, 0);
const activePlans = d.plans.filter((p) => p.status === 'active').length;
const refuted = d.assumptions.filter((a) => a.status === 'refuted').length;
const reviewed = d.reviews[0];
return (
<tr key={d.id} className="hover:bg-muted/30 transition-colors">
<td className="p-3">
<p className="text-xs font-medium text-ink line-clamp-2 max-w-64">
{(d.context ?? '').slice(0, 80)}
</p>
{d.selectedOption && (
<p className="text-[10px] text-ink-faint mt-0.5 truncate max-w-64"> {d.selectedOption}</p>
)}
</td>
<td className="p-3 text-center">
<LifecycleDot count={d.scenarios.length} cls="text-bronze-deep" />
</td>
<td className="p-3 text-center">
<div className="flex flex-col items-center gap-0.5">
<LifecycleDot count={d.plans.length} cls="text-bronze-deep" />
{activePlans > 0 && <span className="text-[9px] text-signal-ok">{activePlans} activ</span>}
</div>
</td>
<td className="p-3 text-center">
<div className="flex flex-col items-center gap-0.5">
<LifecycleDot count={d.assumptions.length} cls="text-bronze-deep" />
{refuted > 0 && <span className="text-[9px] text-signal-danger">{refuted} inf.</span>}
</div>
</td>
<td className="p-3 text-center">
{reviewed ? (
<div className="flex flex-col items-center gap-0.5">
<span className="text-sm">{reviewed.wasSuccessful ? '✅' : reviewed.wasSuccessful === false ? '❌' : '📋'}</span>
{reviewed.rating && <span className="text-[9px] font-medium text-ink">{reviewed.rating}/10</span>}
</div>
) : (
<span className="text-[10px] text-ink-faint/30"></span>
)}
</td>
<td className="p-3 text-right">
{ev !== 0 ? (
<span className={`text-xs font-mono font-medium ${ev > 0 ? 'text-signal-ok' : 'text-signal-danger'}`}>
{ev > 0 ? '+' : ''}{(ev/1000).toFixed(0)}k
</span>
) : (
<span className="text-[10px] text-ink-faint/30"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}