BillTracker/client/components/tracker/MobileTrackerRow.jsx

410 lines
16 KiB
React
Raw Normal View History

2026-05-31 15:06:10 -05:00
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
2026-05-31 15:06:10 -05:00
import { toast } from 'sonner';
import { api } from '@/api.js';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell';
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
function MiniSparkline({ values }) {
if (!values || values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const width = 44;
const height = 14;
const points = values.map((value, index) => {
const x = (index / (values.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="mt-1 opacity-60" aria-hidden="true">
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }) {
2026-05-31 15:06:10 -05:00
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false);
const [quickPaySaving, setQuickPaySaving] = useState(false);
2026-05-31 15:06:10 -05:00
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isSkipped = !!row.is_skipped;
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
const effectiveStatus = isSkipped
? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
? 'paid'
: row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
const summary = paymentSummary(row, threshold);
async function handleQuickPay() {
if (quickPaySaving) return;
2026-05-31 15:06:10 -05:00
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
setQuickPaySaving(true);
2026-05-31 15:06:10 -05:00
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Payment added');
refresh();
} catch (err) {
toast.error(err.message);
} finally {
setQuickPaySaving(false);
2026-05-31 15:06:10 -05:00
}
}
async function performTogglePaid() {
try {
const result = await api.togglePaid(row.id, {
amount: isPaid ? undefined : threshold,
year: year,
month: month,
});
if (isPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else {
toast.success('Payment recorded');
}
refresh();
} catch (err) {
toast.error(err.message || 'Failed to toggle payment status');
}
}
function handleTogglePaid() {
if (isPaid) {
setConfirmUnpay(true);
return;
}
performTogglePaid();
}
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
try {
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
async function handleDismissSuggestion() {
setSuggestionLoading(true);
try {
await api.dismissAutopaySuggestion(row.id, { year, month });
toast.success('Autopay suggestion dismissed');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion');
} finally {
setSuggestionLoading(false);
}
}
return (
<>
<motion.div
layout="position"
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
2026-05-31 15:06:10 -05:00
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg,
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill up"
aria-label={`Move ${row.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill down"
aria-label={`Move ${row.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
{row.name}
</span>
)}
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
title="Autopay"
>
AP
</span>
)}
{(row.has_merchant_rule || row.has_linked_transactions) && (
<span
className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400"
title="Linked to bank transactions"
>
L
</span>
)}
2026-05-31 15:06:10 -05:00
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
S
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
</div>
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
{fmt(threshold)}
</p>
{isDrifted && (
<span className="mt-0.5 inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
<TrendingUp className="h-2.5 w-2.5" />
Changed
</span>
)}
<MiniSparkline values={row.sparkline} />
2026-05-31 15:06:10 -05:00
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
<p className="tracker-number mt-0.5 text-sm font-medium text-muted-foreground/80">
{fmt(row.previous_month_paid)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
{fmt(remaining)}
</p>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/20">
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span>
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div>
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span>
<button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
compact
/>
)}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount"
aria-label={`${row.name} payment amount`}
disabled={quickPaySaving}
2026-05-31 15:06:10 -05:00
/>
<Button
size="sm" variant="default"
onClick={handleQuickPay}
disabled={quickPaySaving}
2026-05-31 15:06:10 -05:00
className="h-8 px-3 text-xs font-semibold"
>
{quickPaySaving ? 'Adding...' : 'Add'}
2026-05-31 15:06:10 -05:00
</Button>
</div>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
compact
/>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</div>
</motion.div>
2026-05-31 15:06:10 -05:00
{editPayment && (
<PaymentModal
payment={editPayment}
autopayEnabled={!!row.autopay_enabled}
2026-05-31 15:06:10 -05:00
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={performTogglePaid}
>
Remove Payment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}