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 ( ); } // ── 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 (
Manage
{active.length} active {inactive.length > 0 && ( · {inactive.length} inactive )}