import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Plus, ChevronRight, SlidersHorizontal } 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;
}
// ── Display preferences ───────────────────────────────────────────────────────
const PREFS_KEY = 'bills-display-prefs-v1';
const PREFS_DEFAULTS = {
showCategory: true,
showDueDay: true,
showAmount: true,
showCycle: true,
showApr: true,
showBalance: true,
showMinPayment: true,
showAutopay: true,
show2fa: true,
};
const PREFS_LABELS = [
['showCategory', 'Category'],
['showDueDay', 'Due day'],
['showAmount', 'Amount'],
['showCycle', 'Billing cycle'],
['showApr', 'APR'],
['showBalance', 'Balance'],
['showMinPayment', 'Min payment'],
['showAutopay', 'Autopay badge'],
['show2fa', '2FA badge'],
];
function useDisplayPrefs() {
const [prefs, setPrefs] = useState(() => {
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS;
} catch {
return PREFS_DEFAULTS;
}
});
const toggle = (key) => {
setPrefs(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
return { prefs, toggle };
}
function DisplayPrefsPanel({ prefs, onToggle }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
setOpen(v => !v)}
className={cn(
'h-9 px-3 rounded-md border border-border/70 bg-card/80 text-xs font-medium',
'flex items-center gap-2 transition-colors',
open
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
aria-label="Display options"
>
Columns
{open && (
Display options
{PREFS_LABELS.map(([key, label]) => (
onToggle(key)}
className="h-3.5 w-3.5 rounded border-border accent-primary"
/>
{label}
))}
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
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 (
{ if (!v && !saving) onClose(); }}>
History Visibility: {bill.name}
{loading ? (
Loading history visibility...
) : (
Inactive bill history
Choose whether this inactive bill should appear in historical tracker views. Active bill behavior is unchanged.
{VISIBILITY_OPTIONS.map(option => (
setVisibility(option.value)}
className="mt-1 h-4 w-4 accent-primary"
/>
{option.label}
{option.description}
))}
{visibility === 'ranges' && (
Selected date ranges
End month/year are optional for open-ended ranges.
setRanges(prev => [...prev, blankRange()])}>
Add range
{ranges.length === 0 ? (
No ranges yet. Add a range to use selected history.
) : (
{ranges.map(range => (
))}
)}
)}
)}
Cancel
{saving ? 'Saving...' : 'Save History Visibility'}
);
}
// ── 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 { prefs, toggle: togglePref } = useDisplayPrefs();
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 (
{/* ── Header ── */}
Manage
Bills
{active.length} active
{inactive.length > 0 && (
· {inactive.length} inactive
)}
setModal({ bill: null })}
className="h-9 px-4 gap-2 text-sm font-medium"
>
Add Bill
{/* ── Active Bills ── */}
Active
{active.length}
{loading ? (
{[...Array(4)].map((_, i) => (
))}
) : active.length === 0 ? (
No active bills.{' '}
setModal({ bill: null })}
className="underline underline-offset-4 hover:text-foreground transition-colors"
>
Add your first bill
) : (
)}
{/* ── Inactive Bills ── */}
{!loading && inactive.length > 0 && (
setShowInactive(v => !v)}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{inactive.length} inactive {inactive.length === 1 ? 'bill' : 'bills'}
{showInactive && (
Inactive
{inactive.length}
)}
)}
{/* ── Bill Modal ── */}
{modal && (
setModal(null)}
onSave={load}
/>
)}
{historyTarget && (
setHistoryTarget(null)}
onSaved={load}
/>
)}
{/* ── Deactivate confirmation (replaces window.confirm) ── */}
{ if (!open) setDeactivate(null); }}>
Deactivate "{deactivate?.name}"?
This bill will be hidden from the tracker. You can reactivate it at any time.
setDeactivate(null)}>Cancel
{ const b = deactivate; setDeactivate(null); doToggle(b); }}
>
Deactivate
{/* ── Permanent delete confirmation ── */}
{
if (!open && !deleteBusy) {
setDeleteTarget(null);
setDeleteConfirmed(false);
}
}}
>
Delete "{deleteTarget?.name}" permanently?
This permanently deletes the bill and all data for this bill, including payments,
monthly history, notes, and history ranges. This cannot be undone.
Deactivate is the safer option if you only want to hide this bill from active tracking.
setDeleteConfirmed(e.target.checked)}
disabled={deleteBusy}
className="mt-0.5 h-4 w-4 rounded border-input bg-input accent-destructive"
/>
I understand this deletes all data for this bill and cannot be undone.
Cancel
{deleteTarget?.active ? 'Deactivate instead' : 'Activate instead'}
{deleteBusy ? 'Deleting…' : 'Delete permanently'}
);
}