|
|
|
|
@ -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,33 +463,47 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
|
|
|
|
|
return fmt(summary.paidTowardDue);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
|
|
|
compact ? 'p-2' : 'px-2 py-1.5',
|
|
|
|
|
)}
|
|
|
|
|
title="View payment history"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2 text-xs">
|
|
|
|
|
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
|
|
|
|
|
{amountLabel}
|
|
|
|
|
</span>
|
|
|
|
|
{summary.count > 1 && (
|
|
|
|
|
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
{summary.count}×
|
|
|
|
|
</span>
|
|
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
|
|
|
compact ? 'p-2' : 'px-2 py-1.5',
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
<div
|
|
|
|
|
className={cn('h-full rounded-full transition-all', barTone)}
|
|
|
|
|
style={{ width: `${summary.percent}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
title="View payment history"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2 text-xs">
|
|
|
|
|
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
|
|
|
|
|
{amountLabel}
|
|
|
|
|
</span>
|
|
|
|
|
{summary.count > 1 && (
|
|
|
|
|
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
{summary.count}×
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
<div
|
|
|
|
|
className={cn('h-full rounded-full transition-all', barTone)}
|
|
|
|
|
style={{ width: `${summary.percent}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">{fmt(row.expected_amount)}</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,40 +1518,56 @@ 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">
|
|
|
|
|
{hasAutopaySuggestion && (
|
|
|
|
|
<AutopaySuggestionActions
|
|
|
|
|
row={row}
|
|
|
|
|
loading={suggestionLoading}
|
|
|
|
|
onConfirm={handleConfirmSuggestion}
|
|
|
|
|
onDismiss={handleDismissSuggestion}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* Quick pay — hidden for skipped bills */}
|
|
|
|
|
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Input
|
|
|
|
|
ref={amountRef}
|
|
|
|
|
type="number" min="0" step="0.01"
|
|
|
|
|
defaultValue={summary.remaining || threshold}
|
|
|
|
|
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
|
|
|
|
|
title="Payment amount"
|
|
|
|
|
/>
|
|
|
|
|
{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={handleQuickPay}
|
|
|
|
|
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
Add
|
|
|
|
|
{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}
|
|
|
|
|
loading={suggestionLoading}
|
|
|
|
|
onConfirm={handleConfirmSuggestion}
|
|
|
|
|
onDismiss={handleDismissSuggestion}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* Quick pay — hidden for skipped/paid bills */}
|
|
|
|
|
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Input
|
|
|
|
|
ref={amountRef}
|
|
|
|
|
type="number" min="0" step="0.01"
|
|
|
|
|
defaultValue={summary.remaining || threshold}
|
|
|
|
|
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
|
|
|
|
|
title="Payment amount"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm" variant="ghost"
|
|
|
|
|
onClick={handleQuickPay}
|
|
|
|
|
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
|
|
|
|
|
>
|
|
|
|
|
Add
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<LowerThisMonthButton
|
|
|
|
|
row={row}
|
|
|
|
|
year={year}
|
|
|
|
|
month={month}
|
|
|
|
|
refresh={refresh}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|