ML predictions Phase 1 & 2: Budget Forecast tab and Cash Flow upgrades
Phase 1 — Budget Forecast: adds a dedicated first-class tab showing all monthly budgets with velocity (£/day), forecast month-end total, dual progress bars, and colour-coded overspend probability badges. Summary bar shows budgets tracked / at-risk count / total forecast overspend. Removes the old BudgetAlerts widget embedded in the Spending tab. Phase 2 — Cash Flow: incorporates known recurring transactions into the 30-day projection (outflows hit on their predicted dates rather than being smeared as averages), adds sqrt-of-time confidence bands to the chart, and shows an upcoming recurring payments list with at-risk highlighting for payments falling on or after the first negative-balance day. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a2c8efd01
commit
3b4787d8b9
3 changed files with 281 additions and 58 deletions
|
|
@ -178,7 +178,9 @@ async def cashflow_forecast(
|
||||||
):
|
):
|
||||||
await _check_prediction_rate(redis, str(user.id))
|
await _check_prediction_rate(redis, str(user.id))
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from app.core.security import decrypt_field
|
||||||
|
|
||||||
# Historical daily cash flows (last 90 days)
|
# Historical daily cash flows (last 90 days)
|
||||||
hist_df = await get_daily_cash_flow(db, user.id, days=90)
|
hist_df = await get_daily_cash_flow(db, user.id, days=90)
|
||||||
|
|
@ -203,28 +205,95 @@ async def cashflow_forecast(
|
||||||
avg_inflow = float(hist_df["inflow"].mean())
|
avg_inflow = float(hist_df["inflow"].mean())
|
||||||
avg_outflow = float(hist_df["outflow"].mean())
|
avg_outflow = float(hist_df["outflow"].mean())
|
||||||
std_net = float((hist_df["inflow"] - hist_df["outflow"]).std())
|
std_net = float((hist_df["inflow"] - hist_df["outflow"]).std())
|
||||||
|
if math.isnan(std_net):
|
||||||
|
std_net = 0.0
|
||||||
else:
|
else:
|
||||||
avg_inflow = 0.0
|
avg_inflow = 0.0
|
||||||
avg_outflow = 0.0
|
avg_outflow = 0.0
|
||||||
std_net = 0.0
|
std_net = 0.0
|
||||||
|
|
||||||
# Project 30 days forward
|
# Fetch recurring transactions (expenses)
|
||||||
|
rec_result = await db.execute(text("""
|
||||||
|
SELECT description_enc, amount::float, recurring_rule, date
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = CAST(:uid AS uuid)
|
||||||
|
AND is_recurring = TRUE
|
||||||
|
AND type = 'expense'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status != 'void'
|
||||||
|
ORDER BY date DESC
|
||||||
|
"""), {"uid": str(user.id)})
|
||||||
|
rec_rows = rec_result.fetchall()
|
||||||
|
|
||||||
|
_FREQ_DAYS = {"daily": 1, "weekly": 7, "fortnightly": 14, "monthly": 30, "quarterly": 91, "yearly": 365}
|
||||||
|
|
||||||
|
# Build a map of date_str -> extra outflow from recurring payments
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
horizon = today + timedelta(days=30)
|
||||||
|
recurring_by_date: dict[str, float] = {}
|
||||||
|
upcoming_payments: list[dict] = []
|
||||||
|
|
||||||
|
# Track which (name, freq) pairs we've already scheduled to avoid duplicates
|
||||||
|
seen_recurring: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for desc_enc, amount, rule, last_date in rec_rows:
|
||||||
|
rule = rule or {}
|
||||||
|
freq = rule.get("frequency", "monthly")
|
||||||
|
interval_days = _FREQ_DAYS.get(freq, 30)
|
||||||
|
try:
|
||||||
|
name = decrypt_field(desc_enc) or "Recurring payment"
|
||||||
|
except Exception:
|
||||||
|
name = "Recurring payment"
|
||||||
|
|
||||||
|
dedup_key = (name.lower()[:30], freq)
|
||||||
|
if dedup_key in seen_recurring:
|
||||||
|
continue
|
||||||
|
seen_recurring.add(dedup_key)
|
||||||
|
|
||||||
|
# Walk forward from last_date until past the horizon
|
||||||
|
next_d = last_date + timedelta(days=interval_days)
|
||||||
|
while next_d <= horizon:
|
||||||
|
if next_d > today:
|
||||||
|
ds = next_d.strftime("%Y-%m-%d")
|
||||||
|
recurring_by_date[ds] = recurring_by_date.get(ds, 0.0) + abs(amount)
|
||||||
|
upcoming_payments.append({
|
||||||
|
"name": name,
|
||||||
|
"date": ds,
|
||||||
|
"amount": round(abs(amount), 2),
|
||||||
|
})
|
||||||
|
next_d += timedelta(days=interval_days)
|
||||||
|
|
||||||
|
upcoming_payments.sort(key=lambda x: x["date"])
|
||||||
|
|
||||||
|
# Project 30 days forward: baseline (avg) + known recurring hits + confidence bands
|
||||||
daily = []
|
daily = []
|
||||||
running_balance = current_balance
|
running_balance = current_balance
|
||||||
for i in range(1, 31):
|
for i in range(1, 31):
|
||||||
d = today + timedelta(days=i)
|
d = today + timedelta(days=i)
|
||||||
net = avg_inflow - avg_outflow
|
ds = d.strftime("%Y-%m-%d")
|
||||||
running_balance += net
|
base_net = avg_inflow - avg_outflow
|
||||||
|
known_outflow = recurring_by_date.get(ds, 0.0)
|
||||||
|
running_balance += base_net - known_outflow
|
||||||
|
|
||||||
|
# Confidence band widens with sqrt of days elapsed
|
||||||
|
band = std_net * math.sqrt(i) if std_net > 0 else 0.0
|
||||||
daily.append({
|
daily.append({
|
||||||
"date": d.strftime("%Y-%m-%d"),
|
"date": ds,
|
||||||
"balance": round(running_balance, 2),
|
"balance": round(running_balance, 2),
|
||||||
|
"upper": round(running_balance + band, 2),
|
||||||
|
"lower": round(running_balance - band, 2),
|
||||||
"avg_inflow": round(avg_inflow, 2),
|
"avg_inflow": round(avg_inflow, 2),
|
||||||
"avg_outflow": round(avg_outflow, 2),
|
"avg_outflow": round(avg_outflow + known_outflow, 2),
|
||||||
"negative_risk": running_balance < 0,
|
"negative_risk": running_balance < 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
negative_days = [d["date"] for d in daily if d["negative_risk"]]
|
negative_days = [d["date"] for d in daily if d["negative_risk"]]
|
||||||
|
negative_day_set = set(negative_days)
|
||||||
|
|
||||||
|
# Flag upcoming payments that fall on or after the first negative-risk day
|
||||||
|
first_negative = min(negative_day_set) if negative_day_set else None
|
||||||
|
for p in upcoming_payments:
|
||||||
|
p["at_risk"] = first_negative is not None and p["date"] >= first_negative
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"current_balance": round(current_balance, 2),
|
"current_balance": round(current_balance, 2),
|
||||||
|
|
@ -232,5 +301,6 @@ async def cashflow_forecast(
|
||||||
"avg_daily_outflow": round(avg_outflow, 2),
|
"avg_daily_outflow": round(avg_outflow, 2),
|
||||||
"forecast": daily,
|
"forecast": daily,
|
||||||
"negative_risk_days": negative_days,
|
"negative_risk_days": negative_days,
|
||||||
|
"upcoming_payments": upcoming_payments,
|
||||||
"history_days": len(hist_df),
|
"history_days": len(hist_df),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,27 @@ export interface BudgetForecastResponse {
|
||||||
export interface CashFlowDay {
|
export interface CashFlowDay {
|
||||||
date: string;
|
date: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
upper: number;
|
||||||
|
lower: number;
|
||||||
avg_inflow: number;
|
avg_inflow: number;
|
||||||
avg_outflow: number;
|
avg_outflow: number;
|
||||||
negative_risk: boolean;
|
negative_risk: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpcomingPayment {
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
at_risk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CashFlowResponse {
|
export interface CashFlowResponse {
|
||||||
current_balance: number;
|
current_balance: number;
|
||||||
avg_daily_inflow: number;
|
avg_daily_inflow: number;
|
||||||
avg_daily_outflow: number;
|
avg_daily_outflow: number;
|
||||||
forecast: CashFlowDay[];
|
forecast: CashFlowDay[];
|
||||||
negative_risk_days: string[];
|
negative_risk_days: string[];
|
||||||
|
upcoming_payments: UpcomingPayment[];
|
||||||
history_days: number;
|
history_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
} from "@/api/predictions";
|
} from "@/api/predictions";
|
||||||
import { formatCurrency } from "@/utils/currency";
|
import { formatCurrency } from "@/utils/currency";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2 } from "lucide-react";
|
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2, PiggyBank, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, BarChart, Bar, LineChart, Line,
|
AreaChart, Area, BarChart, Bar, LineChart, Line,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
|
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
|
||||||
|
|
@ -16,16 +16,17 @@ import { cssVar } from "@/utils/cssVar";
|
||||||
import { TOOLTIP_STYLE, ACTIVE_DOT } from "@/utils/chartTheme";
|
import { TOOLTIP_STYLE, ACTIVE_DOT } from "@/utils/chartTheme";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ id: "budget", label: "Budget", icon: PiggyBank },
|
||||||
|
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
|
||||||
{ id: "spending", label: "Spending", icon: BarChart3 },
|
{ id: "spending", label: "Spending", icon: BarChart3 },
|
||||||
{ id: "networth", label: "Net Worth", icon: TrendingUp },
|
{ id: "networth", label: "Net Worth", icon: TrendingUp },
|
||||||
{ id: "montecarlo", label: "Monte Carlo", icon: Sparkles },
|
{ id: "montecarlo", label: "Monte Carlo", icon: Sparkles },
|
||||||
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Tab = (typeof TABS)[number]["id"];
|
type Tab = (typeof TABS)[number]["id"];
|
||||||
|
|
||||||
export default function PredictionsPage() {
|
export default function PredictionsPage() {
|
||||||
const [tab, setTab] = useState<Tab>("spending");
|
const [tab, setTab] = useState<Tab>("budget");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -51,10 +52,153 @@ export default function PredictionsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "budget" && <BudgetForecastTab />}
|
||||||
|
{tab === "cashflow" && <CashFlowTab />}
|
||||||
{tab === "spending" && <SpendingTab />}
|
{tab === "spending" && <SpendingTab />}
|
||||||
{tab === "networth" && <NetWorthTab />}
|
{tab === "networth" && <NetWorthTab />}
|
||||||
{tab === "montecarlo" && <MonteCarloTab />}
|
{tab === "montecarlo" && <MonteCarloTab />}
|
||||||
{tab === "cashflow" && <CashFlowTab />}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Budget Forecast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BudgetForecastTab() {
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingCard />;
|
||||||
|
|
||||||
|
if (!data || data.forecasts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
|
||||||
|
<PiggyBank className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm font-medium mb-1">No monthly budgets set</p>
|
||||||
|
<p className="text-xs">Set up budgets on the <a href="/budgets" className="text-primary underline underline-offset-2">Budgets page</a> to see forecasts here.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecasts = data.forecasts;
|
||||||
|
const atRisk = forecasts.filter(f => f.probability_overspend > 0.5);
|
||||||
|
const onTrack = forecasts.filter(f => f.probability_overspend <= 0.5);
|
||||||
|
const totalForecastOverspend = atRisk.reduce((sum, f) => sum + Math.max(0, f.forecast_month_total - f.budget_amount), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Budgets tracked</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{forecasts.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className={cn("bg-card border rounded-xl p-4", atRisk.length > 0 ? "border-destructive/40" : "border-border")}>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">At risk this month</p>
|
||||||
|
<p className={cn("text-2xl font-bold tabular-nums", atRisk.length > 0 ? "text-destructive" : "text-success")}>
|
||||||
|
{atRisk.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Forecast overspend</p>
|
||||||
|
<p className={cn("text-2xl font-bold tabular-nums", totalForecastOverspend > 0 ? "text-destructive" : "text-success")}>
|
||||||
|
{totalForecastOverspend > 0 ? formatCurrency(totalForecastOverspend, "GBP") : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{atRisk.length === 0 && (
|
||||||
|
<div className="flex items-center gap-3 bg-success/10 border border-success/30 rounded-xl px-4 py-3">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||||
|
<p className="text-sm text-success font-medium">All budgets on track for this month</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* At-risk budgets */}
|
||||||
|
{atRisk.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-destructive" />
|
||||||
|
<p className="text-sm font-semibold">At Risk</p>
|
||||||
|
</div>
|
||||||
|
{atRisk.map(f => <BudgetForecastCard key={f.category_id} forecast={f} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* On-track budgets */}
|
||||||
|
{onTrack.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||||
|
<p className="text-sm font-semibold">On Track</p>
|
||||||
|
</div>
|
||||||
|
{onTrack.map(f => <BudgetForecastCard key={f.category_id} forecast={f} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetForecastCard({ forecast: f }: { forecast: import("@/api/predictions").BudgetForecastItem }) {
|
||||||
|
const spentPct = Math.min(100, (f.spent_so_far / f.budget_amount) * 100);
|
||||||
|
const forecastPct = Math.min(110, (f.forecast_month_total / f.budget_amount) * 100);
|
||||||
|
const isOverBudget = f.forecast_month_total > f.budget_amount;
|
||||||
|
|
||||||
|
const riskColor =
|
||||||
|
f.probability_overspend > 0.7
|
||||||
|
? "text-destructive"
|
||||||
|
: f.probability_overspend > 0.3
|
||||||
|
? "text-warning"
|
||||||
|
: "text-success";
|
||||||
|
|
||||||
|
const barColor =
|
||||||
|
f.probability_overspend > 0.7
|
||||||
|
? "bg-destructive"
|
||||||
|
: f.probability_overspend > 0.3
|
||||||
|
? "bg-warning"
|
||||||
|
: "bg-success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{f.category_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{f.days_remaining} days remaining · {formatCurrency(f.daily_velocity, "GBP")}/day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs font-bold px-2 py-1 rounded-full bg-card border", riskColor,
|
||||||
|
f.probability_overspend > 0.7 ? "border-destructive/40" :
|
||||||
|
f.probability_overspend > 0.3 ? "border-warning/40" : "border-success/40"
|
||||||
|
)}>
|
||||||
|
{(f.probability_overspend * 100).toFixed(0)}% risk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar: spent so far */}
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>Spent so far</span>
|
||||||
|
<span>{formatCurrency(f.spent_so_far, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full transition-all", barColor)} style={{ width: `${spentPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forecast bar */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>Forecast month-end</span>
|
||||||
|
<span className={cn("font-medium", isOverBudget ? riskColor : "text-foreground")}>
|
||||||
|
{formatCurrency(f.forecast_month_total, "GBP")}
|
||||||
|
{isOverBudget && <span className="ml-1">(+{formatCurrency(f.forecast_month_total - f.budget_amount, "GBP")})</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
|
||||||
|
<div className={cn("h-full rounded-full transition-all opacity-40", barColor)} style={{ width: `${Math.min(100, forecastPct)}%` }} />
|
||||||
|
{/* Budget marker at 100% */}
|
||||||
|
<div className="absolute top-0 right-0 h-full w-px bg-foreground/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -125,48 +269,6 @@ function SpendingTab() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Budget forecast alert cards */}
|
|
||||||
<BudgetAlerts />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BudgetAlerts() {
|
|
||||||
const { data } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
|
|
||||||
if (!data?.forecasts.length) return null;
|
|
||||||
|
|
||||||
const atRisk = data.forecasts.filter(f => f.probability_overspend > 0.5);
|
|
||||||
if (!atRisk.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-card border border-border rounded-xl p-5">
|
|
||||||
<p className="text-sm font-semibold mb-3">Budget Overspend Risk</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{atRisk.slice(0, 5).map(f => {
|
|
||||||
const forecastPct = Math.min(140, (f.forecast_month_total / f.budget_amount) * 100);
|
|
||||||
return (
|
|
||||||
<div key={f.category_id}>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="font-medium">{f.category_name}</span>
|
|
||||||
<span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-warning")}>
|
|
||||||
{(f.probability_overspend * 100).toFixed(0)}% overspend risk
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-warning")}
|
|
||||||
style={{ width: `${Math.min(100, forecastPct)}%` }}
|
|
||||||
/>
|
|
||||||
<div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground mt-0.5">
|
|
||||||
<span>Spent: {formatCurrency(f.spent_so_far, "GBP")}</span>
|
|
||||||
<span>Forecast: {formatCurrency(f.forecast_month_total, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -425,6 +527,7 @@ function CashFlowTab() {
|
||||||
if (!data) return <EmptyCard message="No data available." />;
|
if (!data) return <EmptyCard message="No data available." />;
|
||||||
|
|
||||||
const hasRisk = data.negative_risk_days.length > 0;
|
const hasRisk = data.negative_risk_days.length > 0;
|
||||||
|
const hasBands = data.forecast.some(d => d.upper !== d.balance);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -448,16 +551,23 @@ function CashFlowTab() {
|
||||||
|
|
||||||
{hasRisk && (
|
{hasRisk && (
|
||||||
<div className="flex items-start gap-3 bg-destructive/10 border border-destructive/30 rounded-xl px-4 py-3">
|
<div className="flex items-start gap-3 bg-destructive/10 border border-destructive/30 rounded-xl px-4 py-3">
|
||||||
<span className="text-destructive text-sm font-medium shrink-0">⚠ Negative balance risk</span>
|
<AlertTriangle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">Negative balance risk</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
|
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
|
||||||
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
|
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
<div className="bg-card border border-border rounded-xl p-5">
|
<div className="bg-card border border-border rounded-xl p-5">
|
||||||
<p className="text-sm font-semibold mb-1">30-Day Balance Forecast</p>
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-sm font-semibold">30-Day Balance Forecast</p>
|
||||||
|
{hasBands && <p className="text-xs text-muted-foreground">Shaded = uncertainty band</p>}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mb-4">Based on {data.history_days} days of transaction history</p>
|
<p className="text-xs text-muted-foreground mb-4">Based on {data.history_days} days of transaction history</p>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||||
|
|
@ -469,12 +579,45 @@ function CashFlowTab() {
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => v.slice(5)} />
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => v.slice(5)} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
|
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
|
||||||
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} />
|
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number, name: string) => [formatCurrency(v, "GBP"), name]} />
|
||||||
<ReferenceLine y={0} stroke="hsl(var(--destructive))" strokeDasharray="4 2" />
|
<ReferenceLine y={0} stroke="hsl(var(--destructive))" strokeDasharray="4 2" />
|
||||||
|
{/* Confidence band: upper then lower fills between them */}
|
||||||
|
{hasBands && (
|
||||||
|
<>
|
||||||
|
<Area type="monotone" dataKey="upper" stroke="transparent" fill="hsl(var(--primary) / 0.08)" fillOpacity={1} legendType="none" tooltipType="none" dot={false} activeDot={false} name="Upper" />
|
||||||
|
<Area type="monotone" dataKey="lower" stroke="transparent" fill="hsl(var(--card))" fillOpacity={1} legendType="none" tooltipType="none" dot={false} activeDot={false} name="Lower" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Area type="monotone" dataKey="balance" stroke="hsl(var(--primary))" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" activeDot={ACTIVE_DOT} />
|
<Area type="monotone" dataKey="balance" stroke="hsl(var(--primary))" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" activeDot={ACTIVE_DOT} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming recurring payments */}
|
||||||
|
{data.upcoming_payments.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5">
|
||||||
|
<p className="text-sm font-semibold mb-3">Upcoming Recurring Payments</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.upcoming_payments.map((p, i) => (
|
||||||
|
<div key={i} className={cn(
|
||||||
|
"flex items-center justify-between py-2 px-3 rounded-lg text-sm",
|
||||||
|
p.at_risk ? "bg-destructive/10 border border-destructive/20" : "bg-secondary/40"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{p.at_risk && <AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />}
|
||||||
|
<span className={cn("font-medium", p.at_risk ? "text-destructive" : "")}>{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-muted-foreground">
|
||||||
|
<span className="text-xs">{p.date}</span>
|
||||||
|
<span className={cn("font-medium tabular-nums", p.at_risk ? "text-destructive" : "text-foreground")}>
|
||||||
|
-{formatCurrency(p.amount, "GBP")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue