2026-05-16 15:38:28 -05:00
|
|
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
|
|
|
|
import { useSearchParams } from 'react-router-dom';
|
2026-06-07 01:28:35 -05:00
|
|
|
import { Plus, ChevronRight, SlidersHorizontal, Search, Trash2 } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
2026-06-07 01:28:35 -05:00
|
|
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
2026-05-15 01:36:56 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
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';
|
2026-06-07 01:28:35 -05:00
|
|
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
2026-05-30 20:04:50 -05:00
|
|
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
2026-05-03 19:51:57 -05:00
|
|
|
import BillsTableInner from '@/components/BillsTableInner';
|
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-05-16 15:38:28 -05:00
|
|
|
import { makeBillDraft } from '@/lib/billDrafts';
|
2026-05-30 21:20:51 -05:00
|
|
|
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
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'],
|
|
|
|
|
];
|
2026-05-16 15:38:28 -05:00
|
|
|
const FILTER_ALL = 'all';
|
|
|
|
|
|
|
|
|
|
const BUILT_IN_TEMPLATES = [
|
|
|
|
|
{
|
|
|
|
|
id: 'builtin-utility',
|
|
|
|
|
name: 'Utility',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'Utility Bill',
|
|
|
|
|
due_day: 15,
|
|
|
|
|
expected_amount: 0,
|
|
|
|
|
billing_cycle: 'monthly',
|
|
|
|
|
cycle_type: 'monthly',
|
|
|
|
|
cycle_day: '1',
|
|
|
|
|
autopay_enabled: false,
|
|
|
|
|
autodraft_status: 'none',
|
|
|
|
|
has_2fa: false,
|
|
|
|
|
notes: 'Utility service bill',
|
|
|
|
|
},
|
|
|
|
|
categoryKeywords: ['utility', 'utilities', 'electric', 'water'],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'builtin-credit-card',
|
|
|
|
|
name: 'Credit Card',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'Credit Card',
|
|
|
|
|
due_day: 20,
|
|
|
|
|
expected_amount: 0,
|
|
|
|
|
billing_cycle: 'monthly',
|
|
|
|
|
cycle_type: 'monthly',
|
|
|
|
|
cycle_day: '1',
|
|
|
|
|
autopay_enabled: false,
|
|
|
|
|
autodraft_status: 'none',
|
|
|
|
|
has_2fa: true,
|
|
|
|
|
current_balance: 0,
|
|
|
|
|
minimum_payment: 0,
|
|
|
|
|
interest_rate: 0,
|
|
|
|
|
snowball_include: true,
|
|
|
|
|
notes: 'Credit card minimum payment',
|
|
|
|
|
},
|
|
|
|
|
categoryKeywords: ['credit card', 'credit cards', 'debt'],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'builtin-subscription',
|
|
|
|
|
name: 'Subscription',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'Subscription',
|
|
|
|
|
due_day: 1,
|
|
|
|
|
expected_amount: 0,
|
|
|
|
|
billing_cycle: 'monthly',
|
|
|
|
|
cycle_type: 'monthly',
|
|
|
|
|
cycle_day: '1',
|
|
|
|
|
autopay_enabled: true,
|
|
|
|
|
autodraft_status: 'assumed_paid',
|
|
|
|
|
has_2fa: false,
|
|
|
|
|
notes: 'Recurring subscription',
|
|
|
|
|
},
|
|
|
|
|
categoryKeywords: ['subscription', 'subscriptions', 'entertainment'],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'builtin-loan',
|
|
|
|
|
name: 'Loan',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'Loan Payment',
|
|
|
|
|
due_day: 1,
|
|
|
|
|
expected_amount: 0,
|
|
|
|
|
billing_cycle: 'monthly',
|
|
|
|
|
cycle_type: 'monthly',
|
|
|
|
|
cycle_day: '1',
|
|
|
|
|
autopay_enabled: false,
|
|
|
|
|
autodraft_status: 'none',
|
|
|
|
|
has_2fa: false,
|
|
|
|
|
current_balance: 0,
|
|
|
|
|
minimum_payment: 0,
|
|
|
|
|
interest_rate: 0,
|
|
|
|
|
snowball_include: true,
|
|
|
|
|
notes: 'Installment loan payment',
|
|
|
|
|
},
|
|
|
|
|
categoryKeywords: ['loan', 'loans', 'debt'],
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
// ── 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,
|
2026-05-29 19:21:46 -05:00
|
|
|
showAutopay: true,
|
|
|
|
|
show2fa: true,
|
|
|
|
|
showSubscription: true,
|
2026-05-15 01:36:56 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PREFS_LABELS = [
|
2026-05-29 19:21:46 -05:00
|
|
|
['showCategory', 'Category'],
|
|
|
|
|
['showDueDay', 'Due day'],
|
|
|
|
|
['showAmount', 'Amount'],
|
2026-05-30 21:20:51 -05:00
|
|
|
['showCycle', 'Billing schedule'],
|
2026-05-29 19:21:46 -05:00
|
|
|
['showApr', 'APR'],
|
|
|
|
|
['showBalance', 'Balance'],
|
|
|
|
|
['showMinPayment', 'Min payment'],
|
|
|
|
|
['showAutopay', 'Autopay badge'],
|
|
|
|
|
['show2fa', '2FA badge'],
|
|
|
|
|
['showSubscription', 'Subscription badge'],
|
2026-05-15 01:36:56 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="relative" ref={ref}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => 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"
|
|
|
|
|
>
|
|
|
|
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
|
|
|
<span className="hidden sm:inline">Columns</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-xl p-3 space-y-0.5">
|
|
|
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1 pb-1.5">
|
|
|
|
|
Display options
|
|
|
|
|
</p>
|
|
|
|
|
{PREFS_LABELS.map(([key, label]) => (
|
|
|
|
|
<label
|
|
|
|
|
key={key}
|
|
|
|
|
className="flex items-center gap-2.5 rounded-md px-1.5 py-1.5 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={!!prefs[key]}
|
|
|
|
|
onChange={() => onToggle(key)}
|
|
|
|
|
className="h-3.5 w-3.5 rounded border-border accent-primary"
|
|
|
|
|
/>
|
|
|
|
|
<span className={cn('text-xs', prefs[key] ? 'text-foreground' : 'text-muted-foreground/60')}>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
function amountSearchText(...values) {
|
|
|
|
|
return values
|
|
|
|
|
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
|
|
|
|
.flatMap(value => {
|
|
|
|
|
const num = Number(value);
|
|
|
|
|
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
|
|
|
|
|
})
|
|
|
|
|
.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function billIsDebt(bill) {
|
|
|
|
|
const category = String(bill.category_name || '').toLowerCase();
|
|
|
|
|
return Number(bill.current_balance) > 0
|
|
|
|
|
|| bill.minimum_payment != null
|
|
|
|
|
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FilterChip({ active, children, onClick }) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
|
|
|
|
|
active
|
|
|
|
|
? 'border-primary/50 bg-primary/15 text-primary'
|
|
|
|
|
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 23:53:53 -05:00
|
|
|
const BILLS_SORT_KEY = 'bills_sort_mode';
|
|
|
|
|
const BILLS_CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
|
|
|
|
|
|
|
|
|
|
function normalizedBillCadence(bill) {
|
|
|
|
|
const raw = String(bill?.cycle_type || bill?.billing_cycle || '').toLowerCase();
|
|
|
|
|
if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
|
|
|
|
|
if (raw === 'biweekly') return 'biweekly';
|
|
|
|
|
if (raw.includes('week')) return 'weekly';
|
|
|
|
|
if (raw.includes('quarter')) return 'quarterly';
|
|
|
|
|
if (raw.includes('annual') || raw.includes('year')) return 'annual';
|
|
|
|
|
if (raw.includes('month') || !raw) return 'monthly';
|
|
|
|
|
return 'other';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function billCadenceIndex(bill) {
|
|
|
|
|
const index = BILLS_CADENCE_ORDER.indexOf(normalizedBillCadence(bill));
|
|
|
|
|
return index >= 0 ? index : BILLS_CADENCE_ORDER.length - 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sortBillsByCadence(items) {
|
|
|
|
|
return [...items].sort((a, b) => (
|
|
|
|
|
billCadenceIndex(a) - billCadenceIndex(b)
|
|
|
|
|
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|
|
|
|
|
|| String(a.name || '').localeCompare(String(b.name || ''))
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SortModeButton({ active, children, onClick }) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-7 rounded-md px-3 text-xs font-semibold transition-colors',
|
|
|
|
|
active
|
|
|
|
|
? 'bg-background text-foreground shadow-sm'
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
|
|
|
)}
|
|
|
|
|
aria-pressed={active}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
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 ? (
|
2026-06-07 15:14:09 -05:00
|
|
|
<div className="rounded-lg bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
|
2026-05-03 19:51:57 -05:00
|
|
|
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() {
|
2026-05-16 15:38:28 -05:00
|
|
|
const [searchParams] = useSearchParams();
|
2026-05-03 19:51:57 -05:00
|
|
|
const [bills, setBills] = useState([]);
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
2026-05-16 15:38:28 -05:00
|
|
|
const [savedTemplates, setSavedTemplates] = useState([]);
|
2026-05-03 19:51:57 -05:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [showInactive, setShowInactive] = useState(false);
|
2026-05-16 15:38:28 -05:00
|
|
|
const [search, setSearch] = useState('');
|
2026-05-30 20:04:50 -05:00
|
|
|
const [draggingId, setDraggingId] = useState(null);
|
|
|
|
|
const [dropTargetId, setDropTargetId] = useState(null);
|
|
|
|
|
const [movingBillId, setMovingBillId] = useState(null);
|
2026-05-16 15:38:28 -05:00
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
category: FILTER_ALL,
|
|
|
|
|
cycle: FILTER_ALL,
|
|
|
|
|
autopay: false,
|
|
|
|
|
firstBucket: false,
|
|
|
|
|
fifteenthBucket: false,
|
|
|
|
|
debt: false,
|
|
|
|
|
inactive: false,
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// 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);
|
2026-06-07 14:49:39 -05:00
|
|
|
const [deactivateReason, setDeactivateReason] = useState('');
|
2026-05-03 19:51:57 -05:00
|
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
|
|
|
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
|
|
|
|
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
|
|
|
const [historyTarget, setHistoryTarget] = useState(null);
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
const { prefs, toggle: togglePref } = useDisplayPrefs();
|
|
|
|
|
|
2026-06-06 23:53:53 -05:00
|
|
|
const [billsSort, setBillsSort] = useState(() => (
|
|
|
|
|
localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
|
|
|
|
));
|
2026-06-07 01:28:35 -05:00
|
|
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
2026-06-06 23:53:53 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
localStorage.setItem(BILLS_SORT_KEY, billsSort);
|
|
|
|
|
}, [billsSort]);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
try {
|
2026-05-16 15:38:28 -05:00
|
|
|
const [billsRes, catRes, templateRes] = await Promise.all([
|
2026-05-03 19:51:57 -05:00
|
|
|
api.allBills(),
|
|
|
|
|
api.categories(),
|
2026-05-16 15:38:28 -05:00
|
|
|
api.billTemplates(),
|
2026-05-03 19:51:57 -05:00
|
|
|
]);
|
|
|
|
|
setBills(billsRes || []);
|
|
|
|
|
setCategories(catRes || []);
|
2026-05-16 15:38:28 -05:00
|
|
|
setSavedTemplates(templateRes || []);
|
2026-05-03 19:51:57 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
const querySearch = searchParams.get('search') || '';
|
|
|
|
|
const includeInactive = searchParams.get('inactive') === '1' || searchParams.get('inactive') === 'true';
|
|
|
|
|
if (querySearch) setSearch(querySearch);
|
|
|
|
|
if (includeInactive) {
|
|
|
|
|
setShowInactive(true);
|
|
|
|
|
setFilters(prev => ({ ...prev, inactive: true }));
|
|
|
|
|
}
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (filters.inactive) setShowInactive(true);
|
|
|
|
|
}, [filters.inactive]);
|
|
|
|
|
|
|
|
|
|
const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
|
const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
|
|
|
|
|
const hasFilters = !!(
|
|
|
|
|
search.trim()
|
|
|
|
|
|| filters.category !== FILTER_ALL
|
|
|
|
|
|| filters.cycle !== FILTER_ALL
|
|
|
|
|
|| filters.autopay
|
|
|
|
|
|| filters.firstBucket
|
|
|
|
|
|| filters.fifteenthBucket
|
|
|
|
|
|| filters.debt
|
|
|
|
|
|| filters.inactive
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const resetFilters = () => {
|
|
|
|
|
setSearch('');
|
|
|
|
|
setFilters({
|
|
|
|
|
category: FILTER_ALL,
|
|
|
|
|
cycle: FILTER_ALL,
|
|
|
|
|
autopay: false,
|
|
|
|
|
firstBucket: false,
|
|
|
|
|
fifteenthBucket: false,
|
|
|
|
|
debt: false,
|
|
|
|
|
inactive: false,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
async function handleEdit(billId) {
|
|
|
|
|
try {
|
|
|
|
|
const bill = await api.bill(billId);
|
|
|
|
|
setModal({ bill });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
function handleDuplicateBill(bill) {
|
|
|
|
|
setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleTemplateSelect(value) {
|
|
|
|
|
if (!value || value === 'placeholder') return;
|
|
|
|
|
const builtIn = BUILT_IN_TEMPLATES.find(template => template.id === value);
|
|
|
|
|
if (builtIn) {
|
|
|
|
|
setModal({ bill: null, initialBill: makeBillDraft(builtIn.data, { template: builtIn, categories }) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saved = savedTemplates.find(template => `saved-${template.id}` === value);
|
|
|
|
|
if (saved) {
|
|
|
|
|
setModal({ bill: null, initialBill: makeBillDraft(saved.data, { template: saved, categories }) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
function handleToggle(bill) {
|
|
|
|
|
if (bill.active) {
|
|
|
|
|
// Prompt confirmation before deactivating
|
|
|
|
|
setDeactivate(bill);
|
|
|
|
|
} else {
|
|
|
|
|
doToggle(bill);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 14:49:39 -05:00
|
|
|
async function doToggle(bill, reason) {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!bill) return;
|
|
|
|
|
try {
|
2026-06-07 14:49:39 -05:00
|
|
|
const payload = { active: bill.active ? 0 : 1 };
|
|
|
|
|
if (bill.active && reason) payload.inactive_reason = reason;
|
|
|
|
|
await api.updateBill(bill.id, payload);
|
2026-05-03 19:51:57 -05:00
|
|
|
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));
|
2026-05-16 10:34:32 -05:00
|
|
|
toast.success(`"${bill.name}" moved to recovery`, {
|
|
|
|
|
description: 'It will be permanently purged after 30 days.',
|
|
|
|
|
action: {
|
|
|
|
|
label: 'Undo',
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
try {
|
|
|
|
|
await api.restoreBill(bill.id);
|
|
|
|
|
toast.success(`"${bill.name}" restored`);
|
|
|
|
|
load();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to restore bill');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
const cycleOptions = useMemo(() => (
|
2026-05-30 21:20:51 -05:00
|
|
|
Array.from(new Set(bills.map(scheduleValue))).sort()
|
2026-05-16 15:38:28 -05:00
|
|
|
), [bills]);
|
|
|
|
|
|
|
|
|
|
const filteredBills = useMemo(() => {
|
|
|
|
|
const q = search.trim().toLowerCase();
|
|
|
|
|
return bills.filter(bill => {
|
|
|
|
|
if (filters.inactive && bill.active) return false;
|
|
|
|
|
if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false;
|
2026-05-30 21:20:51 -05:00
|
|
|
if (filters.cycle !== FILTER_ALL && scheduleValue(bill) !== filters.cycle) return false;
|
2026-05-16 15:38:28 -05:00
|
|
|
if (filters.autopay && !bill.autopay_enabled) return false;
|
|
|
|
|
if (filters.debt && !billIsDebt(bill)) return false;
|
|
|
|
|
if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false;
|
|
|
|
|
if (filters.fifteenthBucket && !filters.firstBucket && bill.bucket !== '15th') return false;
|
|
|
|
|
|
|
|
|
|
if (!q) return true;
|
|
|
|
|
const haystack = [
|
|
|
|
|
bill.name,
|
|
|
|
|
bill.category_name,
|
|
|
|
|
bill.notes,
|
2026-05-30 21:20:51 -05:00
|
|
|
scheduleValue(bill),
|
|
|
|
|
scheduleLabel(bill),
|
2026-05-16 15:38:28 -05:00
|
|
|
bill.bucket,
|
|
|
|
|
amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate),
|
|
|
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
|
|
|
return haystack.includes(q);
|
|
|
|
|
});
|
|
|
|
|
}, [bills, filters, search]);
|
|
|
|
|
|
|
|
|
|
const active = filteredBills.filter(b => b.active);
|
|
|
|
|
const inactive = filteredBills.filter(b => !b.active);
|
|
|
|
|
const totalActive = bills.filter(b => b.active).length;
|
|
|
|
|
const totalInactive = bills.filter(b => !b.active).length;
|
2026-06-06 23:53:53 -05:00
|
|
|
const reorderEnabled = !hasFilters && !loading && billsSort === 'custom';
|
|
|
|
|
|
|
|
|
|
const sortedActive = billsSort === 'cadence' ? sortBillsByCadence(active) : active;
|
|
|
|
|
const sortedInactive = billsSort === 'cadence' ? sortBillsByCadence(inactive) : inactive;
|
2026-05-30 20:04:50 -05:00
|
|
|
|
|
|
|
|
async function persistBillOrder(nextBills, movedId) {
|
|
|
|
|
setBills(nextBills);
|
|
|
|
|
setMovingBillId(movedId);
|
|
|
|
|
try {
|
|
|
|
|
await api.reorderBills(reorderPayload(nextBills));
|
|
|
|
|
toast.success('Bill order saved');
|
|
|
|
|
load();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to save bill order');
|
|
|
|
|
load();
|
|
|
|
|
} finally {
|
|
|
|
|
setMovingBillId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reorderBillGroup(activeState, orderedGroup) {
|
|
|
|
|
const sourceGroup = bills.filter(bill => !!bill.active === activeState);
|
|
|
|
|
const replacements = [...orderedGroup];
|
|
|
|
|
const nextBills = bills.map(bill => (
|
|
|
|
|
!!bill.active === activeState ? replacements.shift() : bill
|
|
|
|
|
));
|
|
|
|
|
persistBillOrder(nextBills, movedItemId(sourceGroup, orderedGroup));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveControlsForGroup(group, activeState) {
|
|
|
|
|
return (bill, index) => ({
|
|
|
|
|
enabled: reorderEnabled,
|
|
|
|
|
moving: movingBillId === bill.id,
|
|
|
|
|
canMoveUp: index > 0,
|
|
|
|
|
canMoveDown: index < group.length - 1,
|
|
|
|
|
onMoveUp: () => reorderBillGroup(activeState, moveInArray(group, index, index - 1)),
|
|
|
|
|
onMoveDown: () => reorderBillGroup(activeState, moveInArray(group, index, index + 1)),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dragPropsForGroup(group, activeState) {
|
|
|
|
|
return (bill, index) => {
|
|
|
|
|
if (!reorderEnabled) return { draggable: false };
|
|
|
|
|
return {
|
|
|
|
|
draggable: true,
|
|
|
|
|
isDragging: draggingId === bill.id,
|
|
|
|
|
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
|
|
|
|
|
onDragStart: (event) => {
|
|
|
|
|
setDraggingId(bill.id);
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
event.dataTransfer.setData('text/plain', String(bill.id));
|
|
|
|
|
},
|
|
|
|
|
onDragEnter: () => {
|
|
|
|
|
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
|
|
|
|
},
|
|
|
|
|
onDragOver: (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.dataTransfer.dropEffect = 'move';
|
|
|
|
|
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
|
|
|
|
},
|
|
|
|
|
onDrop: (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
|
|
|
|
const fromIndex = group.findIndex(item => item.id === sourceId);
|
|
|
|
|
if (fromIndex >= 0) reorderBillGroup(activeState, moveInArray(group, fromIndex, index));
|
|
|
|
|
setDraggingId(null);
|
|
|
|
|
setDropTargetId(null);
|
|
|
|
|
},
|
|
|
|
|
onDragEnd: () => {
|
|
|
|
|
setDraggingId(null);
|
|
|
|
|
setDropTargetId(null);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
|
|
|
|
{/* ── Header ── */}
|
2026-05-04 13:14:32 -05:00
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
2026-05-03 19:51:57 -05:00
|
|
|
<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">
|
2026-05-16 15:38:28 -05:00
|
|
|
{totalActive} active
|
|
|
|
|
{totalInactive > 0 && (
|
|
|
|
|
<span className="text-muted-foreground/50"> · {totalInactive} inactive</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
2026-05-15 01:36:56 -05:00
|
|
|
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
|
2026-05-16 15:38:28 -05:00
|
|
|
<Select value="placeholder" onValueChange={handleTemplateSelect}>
|
2026-07-02 20:36:09 -05:00
|
|
|
<SelectTrigger className="h-9 min-w-[160px] flex-1 bg-card sm:w-[180px] sm:flex-none" aria-label="Use a bill template">
|
2026-05-16 15:38:28 -05:00
|
|
|
<SelectValue placeholder="Use template" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="placeholder">Use template...</SelectItem>
|
|
|
|
|
{BUILT_IN_TEMPLATES.map(template => (
|
|
|
|
|
<SelectItem key={template.id} value={template.id}>{template.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
{savedTemplates.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{savedTemplates.map(template => (
|
|
|
|
|
<SelectItem key={template.id} value={`saved-${template.id}`}>
|
|
|
|
|
{template.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-05-15 01:36:56 -05:00
|
|
|
<Button
|
|
|
|
|
onClick={() => setModal({ bill: null })}
|
2026-05-16 15:38:28 -05:00
|
|
|
className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none"
|
2026-05-15 01:36:56 -05:00
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
Add Bill
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-07 01:28:35 -05:00
|
|
|
<SearchFilterPanel
|
|
|
|
|
title="Search & filters"
|
|
|
|
|
collapsed={searchPanelCollapsed}
|
|
|
|
|
onCollapsedChange={setSearchPanelCollapsed}
|
|
|
|
|
hasFilters={hasFilters}
|
|
|
|
|
resultLabel={`${filteredBills.length} of ${bills.length} shown`}
|
|
|
|
|
onClear={resetFilters}
|
|
|
|
|
className="surface-elevated"
|
|
|
|
|
>
|
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px] lg:items-center">
|
2026-05-16 15:38:28 -05:00
|
|
|
<label className="relative">
|
|
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
placeholder="Search bills by name, category, notes, or amount"
|
|
|
|
|
className="h-10 pl-9"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
2026-07-02 20:36:09 -05:00
|
|
|
<SelectTrigger className="h-10" aria-label="Filter by category">
|
2026-05-16 15:38:28 -05:00
|
|
|
<SelectValue placeholder="Category" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
|
|
|
|
|
{categories.map(category => (
|
|
|
|
|
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
2026-07-02 20:36:09 -05:00
|
|
|
<SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
|
2026-05-30 21:20:51 -05:00
|
|
|
<SelectValue placeholder="Billing schedule" />
|
2026-05-16 15:38:28 -05:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
|
|
|
|
|
{cycleOptions.map(cycle => (
|
2026-05-30 21:20:51 -05:00
|
|
|
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
|
2026-05-16 15:38:28 -05:00
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
|
|
|
|
|
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
|
|
|
|
|
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
|
|
|
|
|
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
|
|
|
|
|
<FilterChip active={filters.inactive} onClick={() => toggleFilter('inactive')}>Inactive</FilterChip>
|
|
|
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
{filteredBills.length} of {bills.length} shown
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-06-07 01:28:35 -05:00
|
|
|
</SearchFilterPanel>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
{/* ── Active Bills ── */}
|
2026-05-16 15:38:28 -05:00
|
|
|
{!filters.inactive && (
|
2026-05-15 01:36:56 -05:00
|
|
|
<div className="surface-elevated rounded-xl overflow-hidden">
|
|
|
|
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Active
|
2026-05-03 19:51:57 -05:00
|
|
|
</span>
|
2026-06-06 23:53:53 -05:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{!reorderEnabled && sortedActive.length > 1 && (
|
|
|
|
|
<span className="text-xs text-muted-foreground hidden sm:inline">
|
|
|
|
|
{billsSort === 'cadence' ? 'Switch to Custom to reorder' : 'Clear filters to reorder'}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-0.5">
|
|
|
|
|
<SortModeButton
|
|
|
|
|
active={billsSort === 'custom'}
|
|
|
|
|
onClick={() => setBillsSort('custom')}
|
|
|
|
|
>
|
|
|
|
|
Custom
|
|
|
|
|
</SortModeButton>
|
|
|
|
|
<SortModeButton
|
|
|
|
|
active={billsSort === 'cadence'}
|
|
|
|
|
onClick={() => setBillsSort('cadence')}
|
|
|
|
|
>
|
|
|
|
|
Cadence
|
|
|
|
|
</SortModeButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
2026-05-15 01:36:56 -05:00
|
|
|
<div className="space-y-px p-4">
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
<div key={i} className="h-16 rounded-lg bg-muted/40 animate-pulse mb-2" />
|
2026-05-10 01:35:41 -05:00
|
|
|
))}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
) : active.length === 0 ? (
|
|
|
|
|
<div className="py-16 text-center text-sm text-muted-foreground">
|
2026-05-16 15:38:28 -05:00
|
|
|
{hasFilters ? (
|
|
|
|
|
<>No active bills match your filters.</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
No active bills.{' '}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setModal({ bill: null })}
|
|
|
|
|
className="underline underline-offset-4 hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Add your first bill
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-05-15 01:36:56 -05:00
|
|
|
<BillsTableInner
|
2026-06-06 23:53:53 -05:00
|
|
|
bills={sortedActive}
|
2026-05-15 01:36:56 -05:00
|
|
|
prefs={prefs}
|
|
|
|
|
onEdit={handleEdit}
|
|
|
|
|
onToggle={handleToggle}
|
|
|
|
|
onDelete={handleDeleteRequest}
|
2026-05-16 15:38:28 -05:00
|
|
|
onDuplicate={handleDuplicateBill}
|
2026-06-06 23:53:53 -05:00
|
|
|
moveControlsFor={moveControlsForGroup(sortedActive, true)}
|
|
|
|
|
dragPropsFor={dragPropsForGroup(sortedActive, true)}
|
2026-05-15 01:36:56 -05:00
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-16 15:38:28 -05:00
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
{/* ── Inactive Bills ── */}
|
2026-06-06 23:53:53 -05:00
|
|
|
{!loading && sortedInactive.length > 0 && (
|
2026-05-03 19:51:57 -05:00
|
|
|
<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]">
|
2026-06-06 23:53:53 -05:00
|
|
|
{sortedInactive.length} inactive {sortedInactive.length === 1 ? 'bill' : 'bills'}
|
2026-05-03 19:51:57 -05:00
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
{(showInactive || filters.inactive) && (
|
2026-05-15 01:36:56 -05:00
|
|
|
<div className="surface-elevated rounded-xl overflow-hidden">
|
|
|
|
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Inactive
|
2026-05-03 19:51:57 -05:00
|
|
|
</span>
|
2026-05-30 20:04:50 -05:00
|
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
2026-06-06 23:53:53 -05:00
|
|
|
{sortedInactive.length}
|
2026-05-30 20:04:50 -05:00
|
|
|
</span>
|
2026-05-09 13:03:36 -05:00
|
|
|
</div>
|
2026-05-15 01:36:56 -05:00
|
|
|
<BillsTableInner
|
2026-06-06 23:53:53 -05:00
|
|
|
bills={sortedInactive}
|
2026-05-15 01:36:56 -05:00
|
|
|
prefs={prefs}
|
|
|
|
|
onEdit={handleEdit}
|
|
|
|
|
onToggle={handleToggle}
|
|
|
|
|
onDelete={handleDeleteRequest}
|
|
|
|
|
onHistory={setHistoryTarget}
|
2026-05-16 15:38:28 -05:00
|
|
|
onDuplicate={handleDuplicateBill}
|
2026-06-06 23:53:53 -05:00
|
|
|
moveControlsFor={moveControlsForGroup(sortedInactive, false)}
|
|
|
|
|
dragPropsFor={dragPropsForGroup(sortedInactive, false)}
|
2026-05-15 01:36:56 -05:00
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
{!loading && filters.inactive && inactive.length === 0 && (
|
|
|
|
|
<div className="surface-elevated rounded-xl py-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
No inactive bills match your filters.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
{/* ── Bill Modal ── */}
|
|
|
|
|
{modal && (
|
|
|
|
|
<BillModal
|
2026-05-16 15:38:28 -05:00
|
|
|
key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
|
2026-05-03 19:51:57 -05:00
|
|
|
bill={modal.bill}
|
2026-05-16 15:38:28 -05:00
|
|
|
initialBill={modal.initialBill}
|
2026-05-03 19:51:57 -05:00
|
|
|
categories={categories}
|
|
|
|
|
onClose={() => setModal(null)}
|
|
|
|
|
onSave={load}
|
2026-05-16 15:38:28 -05:00
|
|
|
onDuplicate={handleDuplicateBill}
|
2026-05-03 19:51:57 -05:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{historyTarget && (
|
|
|
|
|
<HistoryVisibilityDialog
|
|
|
|
|
bill={historyTarget}
|
|
|
|
|
onClose={() => setHistoryTarget(null)}
|
|
|
|
|
onSaved={load}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Deactivate confirmation (replaces window.confirm) ── */}
|
2026-06-07 14:49:39 -05:00
|
|
|
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
|
2026-05-03 19:51:57 -05:00
|
|
|
<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>
|
2026-06-07 14:49:39 -05:00
|
|
|
<div className="space-y-1.5 py-1">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
|
|
|
Reason (optional)
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
value={deactivateReason}
|
|
|
|
|
onChange={e => setDeactivateReason(e.target.value)}
|
|
|
|
|
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Select a reason…</option>
|
|
|
|
|
<option value="Moved to spouse">Moved to spouse</option>
|
|
|
|
|
<option value="Switched providers">Switched providers</option>
|
|
|
|
|
<option value="Paid off">Paid off</option>
|
|
|
|
|
<option value="Cancelled">Cancelled</option>
|
|
|
|
|
<option value="Other">Other</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
<AlertDialogFooter>
|
2026-06-07 14:49:39 -05:00
|
|
|
<AlertDialogCancel onClick={() => { setDeactivate(null); setDeactivateReason(''); }}>Cancel</AlertDialogCancel>
|
2026-05-03 19:51:57 -05:00
|
|
|
<AlertDialogAction
|
|
|
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
2026-06-07 14:49:39 -05:00
|
|
|
onClick={() => { const b = deactivate; const r = deactivateReason; setDeactivate(null); setDeactivateReason(''); doToggle(b, r); }}
|
2026-05-03 19:51:57 -05:00
|
|
|
>
|
|
|
|
|
Deactivate
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
{/* ── Soft delete confirmation ── */}
|
2026-05-03 19:51:57 -05:00
|
|
|
<AlertDialog
|
|
|
|
|
open={!!deleteTarget}
|
|
|
|
|
onOpenChange={open => {
|
|
|
|
|
if (!open && !deleteBusy) {
|
|
|
|
|
setDeleteTarget(null);
|
|
|
|
|
setDeleteConfirmed(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<AlertDialogContent className="max-w-lg">
|
|
|
|
|
<AlertDialogHeader>
|
2026-05-16 10:34:32 -05:00
|
|
|
<AlertDialogTitle>Move "{deleteTarget?.name}" to recovery?</AlertDialogTitle>
|
2026-05-03 19:51:57 -05:00
|
|
|
<AlertDialogDescription>
|
2026-05-16 10:34:32 -05:00
|
|
|
This hides the bill from normal tracking and keeps its payments, monthly history,
|
|
|
|
|
notes, and history ranges recoverable for 30 days.
|
2026-05-03 19:51:57 -05:00
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
|
|
|
|
Deactivate is still the best option if you want to retain the bill long-term but hide it from active tracking.
|
2026-05-03 19:51:57 -05:00
|
|
|
</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"
|
|
|
|
|
/>
|
2026-05-16 10:34:32 -05:00
|
|
|
<span>I understand this removes the bill from normal views and will be purged after 30 days if not restored.</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-05-16 10:34:32 -05:00
|
|
|
{deleteBusy ? 'Moving…' : 'Move to recovery'}
|
2026-05-03 19:51:57 -05:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|