BillTracker/client/components/BillsTableInner.jsx

277 lines
10 KiB
JavaScript

import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { MobileBillRow } from '@/components/MobileBillRow';
function ordinal(n) {
const d = Number(n);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
switch (d % 10) {
case 1: return `${d}st`;
case 2: return `${d}nd`;
case 3: return `${d}rd`;
default: return `${d}th`;
}
}
function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
}
function AprColor({ rate }) {
const cls =
rate >= 25 ? 'text-rose-400' :
rate >= 15 ? 'text-amber-400' :
'text-muted-foreground';
return <span className={cn('tracker-number text-[11px] font-semibold', cls)}>{rate}% APR</span>;
}
const ALL_ON = {
showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
};
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) {
const isDebt = bill.current_balance != null || bill.minimum_payment != null;
const hasHistory = hasHistoricalVisibility(bill);
return (
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
'hover:bg-accent/20',
!bill.active && 'opacity-60',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}>
<div className="flex shrink-0 items-center gap-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="hidden flex-col sm:flex">
<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 ${bill.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 ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
{/* Main info */}
<div className="flex-1 min-w-0 space-y-1.5">
{/* Name + badges */}
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => onEdit?.(bill.id)}
className="max-w-[240px] truncate text-left text-sm font-semibold text-foreground transition-colors hover:text-primary"
>
{bill.name}
</button>
{prefs.showCategory && bill.category_name && (
<span className="shrink-0 rounded border border-border/70 bg-secondary/60 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{bill.category_name}
</span>
)}
{prefs.showAutopay && !!bill.autopay_enabled && (
<span className="shrink-0 rounded bg-emerald-500/20 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-300">
Autopay
</span>
)}
{prefs.show2fa && !!bill.has_2fa && (
<span className="shrink-0 rounded bg-violet-500/20 px-1.5 py-0.5 text-[11px] font-semibold text-violet-300">
2FA
</span>
)}
{prefs.showSubscription && !!bill.is_subscription && (
<span className="shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-indigo-600 dark:text-indigo-300">
S
</span>
)}
{!!bill.has_merchant_rule && (
<span
className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
title="Bank matching rule active — transactions import automatically"
>
Bank
</span>
)}
{hasHistory && (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
title="Historical visibility configured"
>
<Clock className="h-2.5 w-2.5" />
</span>
)}
</div>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
{prefs.showCycle && <span>{scheduleLabel(bill)}</span>}
{prefs.showCycle && prefs.showDueDay && <span className="text-border">·</span>}
{prefs.showDueDay && <span>Due {ordinal(bill.due_day)}</span>}
{prefs.showApr && isDebt && bill.interest_rate != null && (
<>
{(prefs.showCycle || prefs.showDueDay) && <span className="text-border">·</span>}
<AprColor rate={bill.interest_rate} />
</>
)}
{prefs.showBalance && isDebt && bill.current_balance != null && (
<>
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
<span className="tracker-number text-[11px] font-medium text-muted-foreground">
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
</span>
</>
)}
</div>
</div>
{/* Amount */}
{prefs.showAmount && (
<div className="text-right shrink-0 hidden sm:block">
<p className="tracker-number text-sm font-bold text-foreground">
${Number(bill.expected_amount).toFixed(2)}
</p>
{prefs.showMinPayment && bill.minimum_payment != null && (
<p className="tracker-number text-[11px] font-medium text-muted-foreground">
${Number(bill.minimum_payment).toFixed(0)} min
</p>
)}
</div>
)}
{/* Action icons */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => onEdit?.(bill.id)}
title="Edit"
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted/70 hover:text-foreground transition-colors"
>
<PenLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onDuplicate?.(bill)}
title="Duplicate"
className="rounded-md p-1.5 text-muted-foreground hover:bg-primary/10 hover:text-primary transition-colors"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!bill.active && onHistory && (
<button
type="button"
onClick={() => onHistory?.(bill)}
title="History visibility"
className="rounded-md p-1.5 text-muted-foreground hover:bg-sky-500/10 hover:text-sky-400 transition-colors"
>
<Clock className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() => onToggle?.(bill)}
title={bill.active ? 'Deactivate' : 'Activate'}
className={cn(
'rounded-md p-1.5 transition-colors',
bill.active
? 'text-muted-foreground hover:bg-amber-500/10 hover:text-amber-400'
: 'text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-400',
)}
>
{bill.active ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => onDelete?.(bill)}
title="Delete"
className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) {
return (
<>
<div className="hidden divide-y divide-border/30 sm:block">
{bills.map((bill, index) => (
<BillCard
key={bill.id}
bill={bill}
prefs={prefs}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
onDuplicate={onDuplicate}
moveControls={moveControlsFor?.(bill, index)}
dragProps={dragPropsFor?.(bill, index)}
/>
))}
</div>
<div className="space-y-3 p-3 sm:hidden">
{bills.map((bill, index) => (
<MobileBillRow
key={bill.id}
bill={bill}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
onDuplicate={onDuplicate}
moveControls={moveControlsFor?.(bill, index)}
dragProps={dragPropsFor?.(bill, index)}
/>
))}
</div>
</>
);
}