BillTracker/client/pages/BillsPage.jsx

620 lines
24 KiB
JavaScript

import { useEffect, useState, useCallback } from 'react';
import { Plus, ChevronRight, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal';
const VISIBILITY_OPTIONS = [
{ value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' },
{ value: 'all', label: 'Show all history', description: 'Show all historical tracker data for this inactive bill.' },
{ value: 'none', label: 'Show no history', description: 'Hide historical tracker data for this inactive bill.' },
{ value: 'ranges', label: 'Show only selected date ranges', description: 'Show history only for the ranges listed below.' },
];
const MONTH_OPTIONS = [
['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'],
['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'],
];
// BillModal is imported from @/components/BillModal
function blankRange() {
const year = String(new Date().getFullYear());
return {
_key: `new-${Date.now()}-${Math.random().toString(16).slice(2)}`,
start_year: year,
start_month: String(new Date().getMonth() + 1),
end_year: '',
end_month: '',
label: '',
};
}
function normalizeRange(range) {
return {
...range,
_key: range._key || String(range.id),
start_year: String(range.start_year ?? ''),
start_month: String(range.start_month ?? ''),
end_year: range.end_year == null ? '' : String(range.end_year),
end_month: range.end_month == null ? '' : String(range.end_month),
label: range.label || '',
};
}
function rangePayload(range) {
return {
start_year: parseInt(range.start_year, 10),
start_month: parseInt(range.start_month, 10),
end_year: range.end_year ? parseInt(range.end_year, 10) : null,
end_month: range.end_month ? parseInt(range.end_month, 10) : null,
label: range.label?.trim() || null,
};
}
function validateRange(range) {
const payload = rangePayload(range);
if (!Number.isInteger(payload.start_year) || payload.start_year < 2000 || payload.start_year > 2100) {
return 'Start year must be between 2000 and 2100.';
}
if (!Number.isInteger(payload.start_month) || payload.start_month < 1 || payload.start_month > 12) {
return 'Start month must be between 1 and 12.';
}
if ((payload.end_year == null) !== (payload.end_month == null)) {
return 'End year and end month must both be provided or both left blank.';
}
if (payload.end_year != null) {
if (payload.end_year < 2000 || payload.end_year > 2100) {
return 'End year must be between 2000 and 2100.';
}
if (payload.end_month < 1 || payload.end_month > 12) {
return 'End month must be between 1 and 12.';
}
if ((payload.end_year * 12 + payload.end_month) < (payload.start_year * 12 + payload.start_month)) {
return 'Range end must be on or after the start.';
}
}
return null;
}
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
const [visibility, setVisibility] = useState(bill?.history_visibility || 'default');
const [ranges, setRanges] = useState([]);
const [deletedIds, setDeletedIds] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let mounted = true;
setLoading(true);
api.billHistoryRanges(bill.id)
.then(data => {
if (!mounted) return;
setVisibility(data.history_visibility || bill.history_visibility || 'default');
setRanges((data.ranges || []).map(normalizeRange));
})
.catch(err => {
toast.error(err.message || 'Failed to load history visibility.');
onClose();
})
.finally(() => mounted && setLoading(false));
return () => { mounted = false; };
}, [bill, onClose]);
const updateRange = (key, patch) => {
setRanges(prev => prev.map(r => r._key === key ? { ...r, ...patch } : r));
};
const deleteRange = (range) => {
if (range.id) setDeletedIds(prev => [...prev, range.id]);
setRanges(prev => prev.filter(r => r._key !== range._key));
};
const save = async () => {
if (visibility === 'ranges') {
if (ranges.length === 0) {
toast.error('Add at least one date range or choose another visibility option.');
return;
}
const invalid = ranges.map(validateRange).find(Boolean);
if (invalid) {
toast.error(invalid);
return;
}
}
setSaving(true);
try {
await api.updateBill(bill.id, { history_visibility: visibility });
for (const id of deletedIds) {
await api.deleteBillHistoryRange(bill.id, id);
}
if (visibility === 'ranges') {
for (const range of ranges) {
const payload = rangePayload(range);
if (range.id) {
await api.updateBillHistoryRange(bill.id, range.id, payload);
} else {
await api.createBillHistoryRange(bill.id, payload);
}
}
}
toast.success('History visibility saved.');
onSaved();
onClose();
} catch (err) {
toast.error(err.message || 'Failed to save history visibility.');
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={v => { if (!v && !saving) onClose(); }}>
<DialogContent className="sm:max-w-4xl border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
History Visibility: {bill.name}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">Loading history visibility...</div>
) : (
<div className="space-y-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<p className="text-sm font-medium">Inactive bill history</p>
<p className="mt-1 text-xs text-muted-foreground">
Choose whether this inactive bill should appear in historical tracker views. Active bill behavior is unchanged.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{VISIBILITY_OPTIONS.map(option => (
<label
key={option.value}
className={cn(
'flex cursor-pointer gap-3 rounded-lg border px-4 py-3 transition-colors',
visibility === option.value
? 'border-primary/40 bg-primary/10'
: 'border-border/60 bg-muted/15 hover:bg-muted/30'
)}
>
<input
type="radio"
name="history_visibility"
value={option.value}
checked={visibility === option.value}
onChange={() => setVisibility(option.value)}
className="mt-1 h-4 w-4 accent-primary"
/>
<span>
<span className="block text-sm font-medium">{option.label}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{option.description}</span>
</span>
</label>
))}
</div>
{visibility === 'ranges' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Selected date ranges</p>
<p className="text-xs text-muted-foreground">End month/year are optional for open-ended ranges.</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={() => setRanges(prev => [...prev, blankRange()])}>
<Plus className="h-4 w-4" />
Add range
</Button>
</div>
{ranges.length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
No ranges yet. Add a range to use selected history.
</div>
) : (
<div className="space-y-2">
{ranges.map(range => (
<div key={range._key} className="grid gap-2 rounded-lg border border-border/60 bg-background/40 p-3 md:grid-cols-[1fr_120px_1fr_120px_1.2fr_auto] md:items-end">
<div className="space-y-1.5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Start year</Label>
<Input
type="number"
min="2000"
max="2100"
value={range.start_year}
onChange={e => updateRange(range._key, { start_year: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Start month</Label>
<Select value={range.start_month} onValueChange={v => updateRange(range._key, { start_month: v })}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_OPTIONS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">End year</Label>
<Input
type="number"
min="2000"
max="2100"
placeholder="Open"
value={range.end_year}
onChange={e => updateRange(range._key, { end_year: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">End month</Label>
<Select value={range.end_month || 'open'} onValueChange={v => updateRange(range._key, { end_month: v === 'open' ? '' : v })}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
{MONTH_OPTIONS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
<Input
value={range.label}
onChange={e => updateRange(range._key, { label: e.target.value })}
placeholder="Optional"
className="h-8"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => deleteRange(range)}
aria-label="Delete history range"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="button" disabled={saving || loading} onClick={save} className="text-xs">
{saving ? 'Saving...' : 'Save History Visibility'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── Bills Page ─────────────────────────────────────────────────────────────
export default function BillsPage() {
const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false);
// modal state: null = closed, { bill: null } = add new, { bill: {...} } = edit
const [modal, setModal] = useState(null);
// Bill pending deactivation confirmation (AlertDialog replaces window.confirm)
const [deactivate, setDeactivate] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const [historyTarget, setHistoryTarget] = useState(null);
const load = useCallback(async () => {
try {
const [billsRes, catRes] = await Promise.all([
api.allBills(),
api.categories(),
]);
setBills(billsRes || []);
setCategories(catRes || []);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
async function handleEdit(billId) {
try {
const bill = await api.bill(billId);
setModal({ bill });
} catch (err) {
toast.error(err.message);
}
}
function handleToggle(bill) {
if (bill.active) {
// Prompt confirmation before deactivating
setDeactivate(bill);
} else {
doToggle(bill);
}
}
async function doToggle(bill) {
if (!bill) return;
try {
await api.updateBill(bill.id, { active: bill.active ? 0 : 1 });
toast.success(bill.active ? 'Bill deactivated' : 'Bill activated');
load();
} catch (err) {
toast.error(err.message);
}
}
function handleDeleteRequest(bill) {
setDeleteConfirmed(false);
setDeleteTarget(bill);
}
async function handleDeleteConfirmed() {
if (!deleteTarget || !deleteConfirmed) return;
setDeleteBusy(true);
try {
const bill = deleteTarget;
await api.deleteBill(bill.id);
setBills(prev => prev.filter(b => b.id !== bill.id));
toast.success(`"${bill.name}" deleted permanently`);
setDeleteTarget(null);
setDeleteConfirmed(false);
load();
} catch (err) {
toast.error(err.message || 'Failed to delete bill');
} finally {
setDeleteBusy(false);
}
}
async function handleDeleteAlternative() {
if (!deleteTarget) return;
const bill = deleteTarget;
setDeleteTarget(null);
setDeleteConfirmed(false);
await doToggle(bill);
}
const active = bills.filter(b => b.active);
const inactive = bills.filter(b => !b.active);
return (
<div className="space-y-6">
{/* ── Header ── */}
<div className="flex items-end justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Manage
</p>
<h1 className="text-2xl font-bold tracking-tight">Bills</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{active.length} active
{inactive.length > 0 && (
<span className="text-muted-foreground/50"> · {inactive.length} inactive</span>
)}
</p>
</div>
<Button
onClick={() => setModal({ bill: null })}
className="h-9 px-4 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
Add Bill
</Button>
</div>
{/* ── Active Bills ── */}
<div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Active Bills
</span>
<span className="text-xs font-mono text-muted-foreground">{active.length}</span>
</div>
{loading ? (
<div className="py-16 text-center text-sm text-muted-foreground animate-pulse">
Loading bills
</div>
) : active.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
No active bills.{' '}
<button
onClick={() => setModal({ bill: null })}
className="underline underline-offset-4 hover:text-foreground transition-colors"
>
Add your first bill
</button>
</div>
) : (
<BillsTableInner
bills={active}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
/>
)}
</div>
{/* ── Inactive Bills ── */}
{!loading && inactive.length > 0 && (
<div className="space-y-3">
<button
onClick={() => setShowInactive(v => !v)}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn(
'h-3 w-3 transition-transform duration-200',
showInactive && 'rotate-90',
)} />
<span className="uppercase tracking-[0.08em]">
{inactive.length} inactive {inactive.length === 1 ? 'bill' : 'bills'}
</span>
</button>
{showInactive && (
<div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Inactive Bills
</span>
<span className="text-xs font-mono text-muted-foreground">{inactive.length}</span>
</div>
<BillsTableInner
bills={inactive}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
onHistory={setHistoryTarget}
/>
</div>
)}
</div>
)}
{/* ── Bill Modal ── */}
{modal && (
<BillModal
bill={modal.bill}
categories={categories}
onClose={() => setModal(null)}
onSave={load}
/>
)}
{historyTarget && (
<HistoryVisibilityDialog
bill={historyTarget}
onClose={() => setHistoryTarget(null)}
onSaved={load}
/>
)}
{/* ── Deactivate confirmation (replaces window.confirm) ── */}
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) setDeactivate(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate "{deactivate?.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This bill will be hidden from the tracker. You can reactivate it at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeactivate(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { const b = deactivate; setDeactivate(null); doToggle(b); }}
>
Deactivate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Permanent delete confirmation ── */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={open => {
if (!open && !deleteBusy) {
setDeleteTarget(null);
setDeleteConfirmed(false);
}
}}
>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>Delete "{deleteTarget?.name}" permanently?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the bill and all data for this bill, including payments,
monthly history, notes, and history ranges. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Deactivate is the safer option if you only want to hide this bill from active tracking.
</div>
<label className="flex items-start gap-3 rounded-lg border border-border bg-muted/25 px-4 py-3 text-sm">
<input
type="checkbox"
checked={deleteConfirmed}
onChange={e => setDeleteConfirmed(e.target.checked)}
disabled={deleteBusy}
className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive"
/>
<span>I understand this deletes all data for this bill and cannot be undone.</span>
</label>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel
disabled={deleteBusy}
className="border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
Cancel
</AlertDialogCancel>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={deleteBusy}
onClick={handleDeleteAlternative}
>
{deleteTarget?.active ? 'Deactivate instead' : 'Activate instead'}
</Button>
<Button
type="button"
variant="destructive"
disabled={!deleteConfirmed || deleteBusy}
onClick={handleDeleteConfirmed}
>
{deleteBusy ? 'Deleting…' : 'Delete permanently'}
</Button>
</div>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}