feat(CC-068): add Decision Reports page (lifecycle matrix: scenarios+plans+outcomes+EV)
This commit is contained in:
parent
488dddea60
commit
afc73c8dc4
1 changed files with 193 additions and 0 deletions
193
src/app/dashboard/reports/decisions/page.tsx
Normal file
193
src/app/dashboard/reports/decisions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue