377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
import { useState, useRef } from 'react';
|
|
import { ArrowDown, ArrowUp, GripVertical, Pencil } from 'lucide-react';
|
|
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 MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
|
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';
|
|
|
|
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
|
|
const amountRef = useRef(null);
|
|
const [editPayment, setEditPayment] = useState(null);
|
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
|
const [showMbs, setShowMbs] = useState(false);
|
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
|
|
|
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() {
|
|
const val = parseFloat(amountRef.current?.value);
|
|
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
|
try {
|
|
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
|
toast.success('Payment added');
|
|
refresh();
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
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.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>
|
|
</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`}
|
|
/>
|
|
<Button
|
|
size="sm" variant="default"
|
|
onClick={handleQuickPay}
|
|
className="h-8 px-3 text-xs font-semibold"
|
|
>
|
|
Add
|
|
</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>
|
|
</div>
|
|
|
|
{editPayment && (
|
|
<PaymentModal
|
|
payment={editPayment}
|
|
onClose={() => setEditPayment(null)}
|
|
onSave={refresh}
|
|
/>
|
|
)}
|
|
|
|
{paymentLedgerOpen && (
|
|
<PaymentLedgerDialog
|
|
row={row}
|
|
year={year}
|
|
month={month}
|
|
threshold={threshold}
|
|
defaultPaymentDate={defaultPaymentDate}
|
|
onClose={() => setPaymentLedgerOpen(false)}
|
|
onSaved={refresh}
|
|
/>
|
|
)}
|
|
|
|
{showMbs && (
|
|
<MonthlyStateDialog
|
|
row={row}
|
|
year={year}
|
|
month={month}
|
|
open={showMbs}
|
|
onOpenChange={setShowMbs}
|
|
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>
|
|
</>
|
|
);
|
|
}
|