import { todayStr } from '@/lib/utils'; export const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; export const FILTER_ALL = 'all'; // Sentinel for the "no method" select option — empty string crashes Radix Select export const METHOD_NONE = 'none'; export const TRACKER_SORT_DEFAULT = 'manual'; export const TRACKER_SORT_ASC = 'asc'; export const TRACKER_SORT_DESC = 'desc'; export const TRACKER_SORT_OPTIONS = [ { key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC }, { key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC }, { key: 'due', label: 'Due date', defaultDir: TRACKER_SORT_ASC }, { key: 'expected', label: 'Expected amount', defaultDir: TRACKER_SORT_DESC }, { key: 'previous', label: 'Last month paid', defaultDir: TRACKER_SORT_DESC }, { key: 'paid', label: 'Paid amount', defaultDir: TRACKER_SORT_DESC }, { key: 'remaining', label: 'Remaining amount', defaultDir: TRACKER_SORT_DESC }, { key: 'paid_date', label: 'Paid date', defaultDir: TRACKER_SORT_DESC }, { key: 'status', label: 'Status', defaultDir: TRACKER_SORT_ASC }, ]; export const TRACKER_SORT_LABELS = Object.fromEntries( TRACKER_SORT_OPTIONS.map(option => [option.key, option.label]) ); export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries( TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir]) ); export const ROW_STATUS_CLS = { paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]', autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]', upcoming: '', due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]', late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25', missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30', }; export const STATUS_META = { paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' }, upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' }, due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' }, late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' }, missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' }, autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; export function paymentDateForTrackerMonth(year, month, dueDay) { const now = new Date(); if (year === now.getFullYear() && month === now.getMonth() + 1) { return todayStr(); } const daysInMonth = new Date(year, month, 0).getDate(); const day = Number.isInteger(Number(dueDay)) ? Math.min(Math.max(Number(dueDay), 1), daysInMonth) : 1; return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } export 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(' '); } export function rowThreshold(row) { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } export function rowEffectiveStatus(row) { if (row.is_skipped) return 'skipped'; const threshold = rowThreshold(row); const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status; } export function rowIsPaid(row) { const status = rowEffectiveStatus(row); if (row.autopay_suggestion && status === 'autodraft') return false; return status === 'paid' || status === 'autodraft'; } export function rowIsDebt(row) { const category = String(row.category_name || '').toLowerCase(); return Number(row.current_balance) > 0 || row.minimum_payment != null || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } const STATUS_SORT_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3, autodraft: 4, paid: 5, skipped: 6, }; function parseDateSortValue(value) { if (!value) return null; const parsed = Date.parse(`${value}T00:00:00`); return Number.isFinite(parsed) ? parsed : null; } function numericSortValue(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } function trackerSortValue(row, key) { switch (key) { case 'name': return String(row.name || '').toLowerCase(); case 'due': return parseDateSortValue(row.due_date) ?? numericSortValue(row.due_day); case 'expected': return numericSortValue(rowThreshold(row)); case 'previous': return numericSortValue(row.previous_month_paid); case 'paid': return numericSortValue(row.total_paid); case 'remaining': return paymentSummary(row, rowThreshold(row)).remaining; case 'paid_date': return parseDateSortValue(row.last_paid_date); case 'status': return STATUS_SORT_ORDER[rowEffectiveStatus(row)] ?? 99; default: return null; } } function compareSortValues(a, b, dir) { const aMissing = a === null || a === undefined || a === ''; const bMissing = b === null || b === undefined || b === ''; if (aMissing && bMissing) return 0; if (aMissing) return 1; if (bMissing) return -1; let result = 0; if (typeof a === 'string' || typeof b === 'string') { result = String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true }); } else { result = a === b ? 0 : (a > b ? 1 : -1); } return dir === TRACKER_SORT_DESC ? -result : result; } export function normalizeTrackerSortKey(key) { return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT; } export function normalizeTrackerSortDir(dir) { return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC; } export function sortTrackerRows(rows, sortKey, sortDir) { const key = normalizeTrackerSortKey(sortKey); if (key === TRACKER_SORT_DEFAULT) return rows; const dir = normalizeTrackerSortDir(sortDir); return rows .map((row, index) => ({ row, index })) .sort((a, b) => { const primary = compareSortValues( trackerSortValue(a.row, key), trackerSortValue(b.row, key), dir ); if (primary !== 0) return primary; const due = compareSortValues(trackerSortValue(a.row, 'due'), trackerSortValue(b.row, 'due'), TRACKER_SORT_ASC); if (due !== 0) return due; const name = compareSortValues(trackerSortValue(a.row, 'name'), trackerSortValue(b.row, 'name'), TRACKER_SORT_ASC); if (name !== 0) return name; return a.index - b.index; }) .map(item => item.row); } export function moveInArray(items, fromIndex, toIndex) { const next = [...items]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); return next; } export function paymentSummary(row, threshold) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) ? Number(row.paid_toward_due) : Math.min(paid, target); const overpaid = Number.isFinite(Number(row.overpaid_amount)) ? Number(row.overpaid_amount) : Math.max(paid - target, 0); const remaining = Math.max(target - paidTowardDue, 0); const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0; return { target, paid, paidTowardDue, overpaid, remaining, percent, count: Array.isArray(row.payments) ? row.payments.length : 0, partial: paid > 0 && remaining > 0, }; }