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:
parent
a0fe7880df
commit
c26880da89
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue