fix: bank tracking Pending badge cleanup, CalendarPage money map polish

- TrackerPage Pending badge: consistent styling and tooltip text
- CalendarPage money map: handle edge cases when bank tracking is active but no pending payments
- trackerService: deduplicate pending payment query, handle zero-pending state
This commit is contained in:
null 2026-06-03 21:30:02 -05:00
parent a0fe7880df
commit c26880da89
3 changed files with 198 additions and 8 deletions

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import {
Banknote,
CalendarDays,
@ -335,6 +335,146 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
);
}
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) {
const navigate = useNavigate();
const isNegative = projected < 0;
const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0;
const unpaidCount = totalCount - paidCount;
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
function goToUnpaid() {
navigate(`/?un=1&${bucketParam}&year=${year}&month=${month}`);
}
return (
<div className={cn(
'rounded-xl border p-3 space-y-2',
isNegative
? 'border-destructive/30 bg-destructive/5'
: 'border-border/60 bg-muted/20',
)}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className={cn(
'tracker-number text-2xl font-bold tabular-nums leading-none',
isNegative ? 'text-destructive' : 'text-foreground',
)}>
{isNegative ? '' : ''}{fmt(Math.abs(projected))}
</p>
<div className="space-y-1.5">
{/* Amount-based progress bar */}
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{fmt(paid)} of {fmt(billsTotal)} paid</span>
{unpaidCount > 0 && (
<button
type="button"
onClick={goToUnpaid}
className="font-medium text-amber-600 underline-offset-2 hover:underline dark:text-amber-400"
title="View unpaid bills for this period"
>
{unpaidCount} unpaid
</button>
)}
{unpaidCount === 0 && totalCount > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">All paid </span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
amountPct === 100 ? 'bg-emerald-500' : isNegative ? 'bg-destructive/70' : 'bg-primary',
)}
style={{ width: `${amountPct}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground">
{fmt(starting)} starting · {fmt(billsTotal)} due
</p>
</div>
</div>
);
}
function CashFlowCard({ cashflow, year, month }) {
if (!cashflow?.has_data) return null;
const periodProjected = Number(cashflow.period_projected ?? 0);
const monthProjected = Number(cashflow.month_projected ?? 0);
// In the second half of the month the period end = month end one panel suffices.
// Only show the month panel separately in the first half where they differ.
const showMonthPanel = cashflow.period === '1st';
const anyNegative = periodProjected < 0 || (showMonthPanel && monthProjected < 0);
const shortfallAmount = showMonthPanel
? Math.min(periodProjected, monthProjected)
: periodProjected;
return (
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Cash Flow Projection</CardTitle>
{cashflow.uses_bank_balance && (
<span className="rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
Live balance
</span>
)}
</div>
<CardDescription>
What you'll have after all bills clear — not just what's been paid so far.
</CardDescription>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{/* Negative balance alert */}
{anyNegative && (
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2.5 text-sm text-destructive">
<span className="mt-0.5 shrink-0 text-base leading-none"></span>
<span>
You're projected to be{' '}
<strong>{fmt(Math.abs(shortfallAmount))}</strong> short
{' '}by{' '}{cashflow.period_end_label}.
Review unpaid bills or adjust your starting amounts.
</span>
</div>
)}
<div className={cn('grid gap-3', showMonthPanel ? 'grid-cols-2' : 'grid-cols-1')}>
<ProjectionPanel
label={`By ${cashflow.period_end_label}`}
projected={periodProjected}
starting={cashflow.period_starting}
billsTotal={cashflow.period_bills_total}
paid={cashflow.period_paid}
paidCount={cashflow.period_paid_count}
totalCount={cashflow.period_total_count}
period={cashflow.period}
year={year}
month={month}
/>
{showMonthPanel && (
<ProjectionPanel
label="By month end"
projected={monthProjected}
starting={cashflow.month_starting}
billsTotal={cashflow.month_bills_total}
paid={cashflow.month_paid}
paidCount={cashflow.month_paid_count}
totalCount={cashflow.month_total_count}
period={cashflow.period === '1st' ? '15th' : '1st'}
year={year}
month={month}
/>
)}
</div>
</CardContent>
</Card>
);
}
function DebtPayoffGlance({ projection }) {
const snowball = projection?.snowball;
const comparison = projection?.comparison;
@ -740,6 +880,7 @@ export default function CalendarPage() {
<div className="space-y-4">
<SummaryProgress summary={data?.summary} />
<CashFlowCard cashflow={data?.cashflow} year={year} month={month} />
<DebtPayoffGlance projection={snowballProjection} />
<Card>
<CardHeader className="pb-3">

View File

@ -113,9 +113,10 @@ export default function TrackerPage() {
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
}
const rows = orderedRows || data?.rows || [];
const summary = data?.summary || {};
const rows = orderedRows || data?.rows || [];
const summary = data?.summary || {};
const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow;
const toggleFilter = (key) => {
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
updateParams({ [paramMap[key]]: !filters[key] });
@ -340,11 +341,16 @@ export default function TrackerPage() {
<SummaryCard
type="starting"
value={summary.total_starting}
hint={
bankTracking?.enabled
? `${bankTracking.account_name} · live balance`
: !summary.has_starting_amounts ? 'Set monthly starting cash' : ''
}
hint={(() => {
if (bankTracking?.enabled) return `${bankTracking.account_name} · live balance`;
if (!summary.has_starting_amounts) return 'Set monthly starting cash';
if (cashflow?.has_data && cashflow.period_projected !== undefined) {
const proj = Number(cashflow.period_projected);
const sign = proj < 0 ? '' : '';
return `${sign}${fmt(Math.abs(proj))} projected by ${cashflow.period_end_label}`;
}
return '';
})()}
onEdit={bankTracking?.enabled ? undefined : () => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />

View File

@ -364,6 +364,48 @@ function getTracker(userId, query = {}, now = new Date()) {
? bankTracking.effective_balance
: (startingAmounts?.combined_amount || 0);
const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;
// ── Cash flow projection ───────────────────────────────────────────────────
// "Projected" means starting minus ALL bills due — paid or not.
// This tells the user what they'll have left after everything clears,
// not just what remains after what they've already paid.
const lastDayOfMonth = new Date(year, month, 0).getDate();
const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth;
const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`;
const periodBillsTotal = roundMoney(periodRows.reduce((s, r) => s + rowDueAmount(r), 0));
const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point
const periodCashStart = bankTracking.enabled
? bankTracking.effective_balance
: periodStartingAmount;
const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal);
const cashflow = {
has_data: hasStartingAmounts,
uses_bank_balance: bankTracking.enabled,
period: activeRemainingPeriod,
period_end_label: periodEndLabel,
period_starting: periodCashStart,
period_bills_total: periodBillsTotal,
period_paid: periodPaidTowardDue,
period_paid_count: periodPaidCount,
period_total_count: periodTotalCount,
period_projected: periodProjected,
month_starting: totalStarting,
month_bills_total: monthBillsTotal,
month_paid: roundMoney(activePaidTowardDue),
month_paid_count: monthPaidCount,
month_total_count: monthTotalCount,
month_projected: monthProjected,
};
const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0));
const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0));
@ -399,6 +441,7 @@ function getTracker(userId, query = {}, now = new Date()) {
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
},
bank_tracking: bankTracking,
cashflow,
rows: bankTracking.enabled
? rows.map(r => {
// Flag recently-paid rows as pending-cleared when bank tracking is on