diff --git a/src/app/dashboard/reports/decisions/page.tsx b/src/app/dashboard/reports/decisions/page.tsx new file mode 100644 index 0000000..6d86a12 --- /dev/null +++ b/src/app/dashboard/reports/decisions/page.tsx @@ -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 = { 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 0; + return {count}; +} + +export default function DecisionReportsPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + + const { data: decisions = [], isLoading: dl } = useQuery({ + queryKey: ['dec-r', tenantId], + queryFn: () => apiFetch('/v1/decisions?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: scenarios = [] } = useQuery({ + queryKey: ['scen-r', tenantId], + queryFn: () => apiFetch('/v1/scenarios', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: actionPlans = [] } = useQuery({ + queryKey: ['ap-r', tenantId], + queryFn: () => apiFetch('/v1/action-plans', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: reviews = [] } = useQuery({ + queryKey: ['or-r', tenantId], + queryFn: () => apiFetch('/v1/outcome-reviews', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: assumptions = [] } = useQuery({ + queryKey: ['ass-r', tenantId], + queryFn: () => apiFetch('/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 ( +
+
+

Raport Decizii

+

+ {dl ? 'Se încarcă…' : `${decisions.length} decizii · ${scenarios.length} scenarii · ${actionPlans.length} planuri de acțiune`} +

+
+ + {/* Summary */} +
+
+

{decisions.length}

+

Decizii totale

+
+
+

= 70 ? 'text-signal-ok' : 'text-signal-warn'}`}> + {successRate !== null ? `${successRate}%` : '—'} +

+

Rată succes

+
+
+

= 7 ? 'text-signal-ok' : avgRating >= 5 ? 'text-signal-warn' : 'text-signal-danger'}`}> + {withReviews.length > 0 ? `${avgRating.toFixed(1)}` : '—'} +

+

Rating mediu /10

+
+
+

= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}> + {totalEV !== 0 ? `${totalEV > 0 ? '+' : ''}${(totalEV/1000).toFixed(0)}k` : '—'} +

+

EV ponderat (RON)

+
+
+ + {/* Decision lifecycle table */} +
+ + + + + + + + + + + + + {dl ? ( + + ) : enrich.length === 0 ? ( + + ) : 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 ( + + + + + + + + + ); + })} + +
DecizieScenariiPlanuriAsumpțiiRevizuiriEV
Se încarcă…
Nicio decizie.
+

+ {(d.context ?? '').slice(0, 80)} +

+ {d.selectedOption && ( +

→ {d.selectedOption}

+ )} +
+ + +
+ + {activePlans > 0 && {activePlans} activ} +
+
+
+ + {refuted > 0 && {refuted} inf.} +
+
+ {reviewed ? ( +
+ {reviewed.wasSuccessful ? '✅' : reviewed.wasSuccessful === false ? '❌' : '📋'} + {reviewed.rating && {reviewed.rating}/10} +
+ ) : ( + + )} +
+ {ev !== 0 ? ( + 0 ? 'text-signal-ok' : 'text-signal-danger'}`}> + {ev > 0 ? '+' : ''}{(ev/1000).toFixed(0)}k + + ) : ( + + )} +
+
+
+ ); +}