bill tracker futurue
This commit is contained in:
parent
fa60ea8fbd
commit
e8218a3dd8
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -448,7 +448,7 @@ function paymentSummary(row, threshold) {
|
|||
};
|
||||
}
|
||||
|
||||
function PaymentProgress({ row, threshold, onOpen, compact = false }) {
|
||||
function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
|
||||
const summary = paymentSummary(row, threshold);
|
||||
const barTone = summary.remaining === 0
|
||||
? 'bg-emerald-500'
|
||||
|
|
@ -463,7 +463,10 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
|
|||
return fmt(summary.paidTowardDue);
|
||||
})();
|
||||
|
||||
const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
|
|
@ -490,6 +493,17 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
|
|||
/>
|
||||
</div>
|
||||
</button>
|
||||
{showQuickFix && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkFullAmount}
|
||||
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
|
||||
>
|
||||
✓ {fmt(summary.paidTowardDue)} is the full amount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1212,10 +1226,14 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
||||
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
||||
const [nudgeAmount, setNudgeAmount] = useState(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// Effective amount threshold for this bill this month:
|
||||
// actual_amount (if set by monthly override) takes priority over the template expected_amount.
|
||||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
// Effective amount threshold: optimistic override → monthly override → template default.
|
||||
const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
|
||||
const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
|
||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
|
||||
const isSkipped = !!row.is_skipped;
|
||||
|
|
@ -1290,6 +1308,55 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
performTogglePaid();
|
||||
}
|
||||
|
||||
async function handleMarkFullAmount() {
|
||||
const newActual = summary.paidTowardDue;
|
||||
setOptimisticActual(newActual);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year, month,
|
||||
actual_amount: newActual,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
setNudgeAmount(newActual);
|
||||
setShowUpdateNudge(true);
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
setOptimisticActual(undefined);
|
||||
toast.error(err.message || 'Failed to update amount');
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateTemplate() {
|
||||
const amount = nudgeAmount;
|
||||
setShowUpdateNudge(false);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await api.updateBill(row.id, { expected_amount: amount });
|
||||
toast.success(`Default updated to ${fmt(amount)}`);
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApplySuggestion(amount) {
|
||||
setOptimisticActual(amount);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year, month,
|
||||
actual_amount: amount,
|
||||
notes: row.monthly_notes || null,
|
||||
is_skipped: row.is_skipped,
|
||||
});
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
setOptimisticActual(undefined);
|
||||
toast.error(err.message || 'Failed to apply suggestion');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmSuggestion() {
|
||||
setSuggestionLoading(true);
|
||||
try {
|
||||
|
|
@ -1382,15 +1449,28 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
|
||||
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
||||
<TableCell className="w-[10%] py-3 text-right font-mono text-sm">
|
||||
{row.actual_amount != null ? (
|
||||
{effectiveActual != null ? (
|
||||
<span
|
||||
className="text-amber-500"
|
||||
title={`Monthly override. Template default: ${fmt(row.expected_amount)}`}
|
||||
>
|
||||
{fmt(row.actual_amount)}
|
||||
{fmt(effectiveActual)}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className="text-muted-foreground">{fmt(row.expected_amount)}</span>
|
||||
{row.amount_suggestion?.suggestion != null &&
|
||||
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
|
||||
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
||||
title={`Based on last ${row.amount_suggestion.months_used} months`}
|
||||
>
|
||||
~{fmt(row.amount_suggestion.suggestion)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -1405,6 +1485,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
row={row}
|
||||
threshold={threshold}
|
||||
onOpen={() => setPaymentLedgerOpen(true)}
|
||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -1437,6 +1518,27 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{/* Actions */}
|
||||
<TableCell className="w-[10%] py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{showUpdateNudge ? (
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
|
||||
<span className="text-[10px] text-muted-foreground">Update default?</span>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={handleUpdateTemplate}
|
||||
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||
>
|
||||
{fmt(nudgeAmount)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateNudge(false)}
|
||||
className="text-muted-foreground/40 transition-colors hover:text-muted-foreground"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hasAutopaySuggestion && (
|
||||
<AutopaySuggestionActions
|
||||
row={row}
|
||||
|
|
@ -1445,7 +1547,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
onDismiss={handleDismissSuggestion}
|
||||
/>
|
||||
)}
|
||||
{/* Quick pay — hidden for skipped bills */}
|
||||
{/* Quick pay — hidden for skipped/paid bills */}
|
||||
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
|
|
@ -1464,13 +1566,8 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<LowerThisMonthButton
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Computes a suggested expected amount for a bill based on the rolling median
|
||||
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
|
||||
* (user-corrected values) over raw payment sums.
|
||||
*/
|
||||
function computeAmountSuggestion(db, billId, year, month) {
|
||||
const amounts = [];
|
||||
let y = year;
|
||||
let m = month;
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
m -= 1;
|
||||
if (m === 0) { m = 12; y -= 1; }
|
||||
|
||||
const mbs = db.prepare(
|
||||
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
|
||||
).get(billId, y, m);
|
||||
|
||||
if (mbs?.actual_amount != null) {
|
||||
amounts.push(mbs.actual_amount);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM payments
|
||||
WHERE bill_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND strftime('%Y', paid_date) = ?
|
||||
AND strftime('%m', paid_date) = ?
|
||||
`).get(billId, String(y), String(m).padStart(2, '0'));
|
||||
|
||||
if (result.total > 0) amounts.push(result.total);
|
||||
}
|
||||
|
||||
if (amounts.length === 0) return null;
|
||||
|
||||
const sorted = [...amounts].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
|
||||
return {
|
||||
suggestion: Math.round(median * 100) / 100,
|
||||
months_used: amounts.length,
|
||||
confidence: amounts.length >= 3 ? 'high' : 'low',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeAmountSuggestion };
|
||||
|
|
@ -4,6 +4,7 @@ const { getDb } = require('../db/database');
|
|||
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
|
||||
const { getUserSettings } = require('./userSettings');
|
||||
const { computeBalanceDelta } = require('./billsService');
|
||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||
|
||||
function validateTrackerMonth(query = {}, now = new Date()) {
|
||||
const year = parseInt(query.year || now.getFullYear(), 10);
|
||||
|
|
@ -263,6 +264,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
row.is_skipped = !!(mbs?.is_skipped);
|
||||
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
||||
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
|
||||
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
|
||||
return row;
|
||||
}).filter(Boolean);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue