From b124e48ebcf2a9d16b00ea4f20330521531353db Mon Sep 17 00:00:00 2001 From: null Date: Sat, 16 May 2026 15:38:28 -0500 Subject: [PATCH] v0.28.0 --- client/App.jsx | 18 +- client/api.js | 21 +- client/components/BillModal.jsx | 156 ++++- client/components/BillsTableInner.jsx | 58 +- client/components/CommandPalette.jsx | 216 ++++++ client/components/MobileBillRow.jsx | 13 +- client/components/layout/Sidebar.jsx | 21 +- client/lib/billDrafts.js | 40 ++ client/pages/BillsPage.jsx | 349 +++++++++- client/pages/DataPage.jsx | 140 +++- client/pages/HealthPage.jsx | 16 +- client/pages/RoadmapPage.jsx | 35 +- client/pages/SnowballPage.jsx | 8 +- client/pages/TrackerPage.jsx | 638 +++++++++++++++++- db/.restore-1777763192032-96266b49.sqlite-shm | Bin 32768 -> 0 bytes db/.restore-1777763192032-96266b49.sqlite-wal | 0 db/database.js | 113 ++++ db/schema.sql | 27 + routes/admin.js | 231 +------ routes/analytics.js | 284 +------- routes/bills.js | 244 +++---- routes/payments.js | 174 ++++- routes/tracker.js | 314 +-------- services/analyticsService.js | 289 ++++++++ services/billsService.js | 155 +++++ services/oidcService.js | 217 +++++- services/spreadsheetImportService.js | 47 +- services/statusService.js | 9 +- services/trackerService.js | 366 ++++++++++ 29 files changed, 3093 insertions(+), 1106 deletions(-) create mode 100644 client/components/CommandPalette.jsx create mode 100644 client/lib/billDrafts.js delete mode 100644 db/.restore-1777763192032-96266b49.sqlite-shm delete mode 100644 db/.restore-1777763192032-96266b49.sqlite-wal create mode 100644 services/analyticsService.js create mode 100644 services/trackerService.js diff --git a/client/App.jsx b/client/App.jsx index 7c741a6..640f01e 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -5,6 +5,7 @@ import { useAuth } from '@/hooks/useAuth'; import Layout from '@/components/layout/Layout'; import AppNavigation from '@/components/layout/Sidebar'; import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog'; +import CommandPalette from '@/components/CommandPalette'; import LoginPage from '@/pages/LoginPage'; import ErrorBoundary from '@/components/ErrorBoundary'; import PageLoader from '@/components/PageLoader'; @@ -81,7 +82,7 @@ function AdminShell({ children }) { return (
-
+
{children}
@@ -96,6 +97,7 @@ export default function App() { {/* Release notes (only for user role) */} {user?.role === 'user' && } + {user && !user.is_default_admin && } {/* Skip link for keyboard users */} } /> + + + + }> + + + + + + } + /> _fetch('POST', path, body); const put = (path, body) => _fetch('PUT', path, body); const del = (path) => _fetch('DELETE', path); +function queryString(params = {}) { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); + }); + const value = qs.toString(); + return value ? `?${value}` : ''; +} + function filenameFromDisposition(value) { if (!value) return null; const match = value.match(/filename="?([^"]+)"?/i); @@ -126,7 +135,7 @@ export const api = { profileImportHistory: () => get('/profile/import-history'), // Tracker - tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`), + tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), // Calendar @@ -139,8 +148,8 @@ export const api = { updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data), // Bills - bills: () => get('/bills'), - allBills: () => get('/bills?inactive=true'), + bills: (params = {}) => get(`/bills${queryString(params)}`), + allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), @@ -156,6 +165,7 @@ export const api = { updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), deleteBill: (id) => del(`/bills/${id}`), restoreBill: (id) => post(`/bills/${id}/restore`), + duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), @@ -164,9 +174,14 @@ export const api = { createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data), updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data), deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`), + billTemplates: () => get('/bills/templates'), + saveBillTemplate: (data) => post('/bills/templates', data), + deleteBillTemplate: (id) => del(`/bills/templates/${id}`), // Payments quickPay: (data) => post('/payments/quick', data), + confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), + dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), bulkPay: (items) => post('/payments/bulk', items), createPayment: (data) => post('/payments', data), updatePayment: (id, data) => put(`/payments/${id}`, data), diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 0ae2578..56d890b 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, Copy } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -40,40 +40,46 @@ function isSnowballCat(categories, catId) { return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; } -export default function BillModal({ bill, categories, onClose, onSave }) { +export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) { const isNew = !bill; + const sourceBill = bill || initialBill || null; - const [name, setName] = useState(bill?.name || ''); - const [categoryId, setCategoryId] = useState(bill?.category_id ? String(bill.category_id) : CAT_NONE); - const [dueDay, setDueDay] = useState(String(bill?.due_day || '')); - const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); - const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); - const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); - const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly'); - const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1'); - const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); - const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); - const [website, setWebsite] = useState(bill?.website || ''); - const [username, setUsername] = useState(bill?.username || ''); - const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); - const [notes, setNotes] = useState(bill?.notes || ''); - const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance)); - const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); - const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); - const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt); + const [name, setName] = useState(sourceBill?.name || ''); + const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE); + const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || '')); + const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || '')); + const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); + const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly'); + const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly'); + const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1'); + const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled); + const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); + const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid); + const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa); + const [website, setWebsite] = useState(sourceBill?.website || ''); + const [username, setUsername] = useState(sourceBill?.username || ''); + const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || ''); + const [notes, setNotes] = useState(sourceBill?.notes || ''); + const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance)); + const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment)); + const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include); + const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt); const [showDebtSection, setShowDebtSection] = useState( - () => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE) - || !!bill?.snowball_include - || !!bill?.snowball_exempt - || bill?.current_balance != null - || bill?.minimum_payment != null + () => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE) + || !!sourceBill?.snowball_include + || !!sourceBill?.snowball_exempt + || sourceBill?.current_balance != null + || sourceBill?.minimum_payment != null ); + const [saveTemplate, setSaveTemplate] = useState(false); + const [templateName, setTemplateName] = useState(''); const [busy, setBusy] = useState(false); const [errors, setErrors] = useState({}); const isDebtCategory = isDebtCat(categories, categoryId); const isSnowballCategory = isSnowballCat(categories, categoryId); const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); + const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid'; const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; @@ -158,6 +164,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) { } }; + const handleAutopayChange = (checked) => { + setAutopay(checked); + if (checked) { + setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid')); + } else { + setAutodraftStatus('none'); + setAutoMarkPaid(false); + } + }; + async function handleSubmit(e) { e.preventDefault(); @@ -180,34 +196,49 @@ export default function BillModal({ bill, categories, onClose, onSave }) { } const data = { + source_bill_id: sourceBill?.source_bill_id, name: name.trim(), category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10), due_day: parsedDueDay, + override_due_date: sourceBill?.override_due_date, expected_amount: parseFloat(expectedAmount) || 0, interest_rate: parsedInterestRate, billing_cycle: billingCycle, cycle_type: cycleType, cycle_day: cycleDay, autopay_enabled: autopay, + autodraft_status: autopay ? autodraftStatus : 'none', + auto_mark_paid: canAutoMarkPaid && autoMarkPaid, has_2fa: has2fa, website: website || null, username: username || null, account_info: accountInfo || null, notes: notes || null, + history_visibility: sourceBill?.history_visibility, current_balance: currentBalance === '' ? null : parseFloat(currentBalance), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), + snowball_order: sourceBill?.snowball_order, snowball_include: snowballInclude, snowball_exempt: snowballExempt, }; setBusy(true); try { if (isNew) { - await api.createBill(data); + if (data.source_bill_id) { + await api.duplicateBill(data.source_bill_id, data); + } else { + await api.createBill(data); + } toast.success('Bill added'); } else { await api.updateBill(bill.id, data); toast.success('Bill updated'); } + if (saveTemplate) { + const safeTemplateName = templateName.trim() || data.name; + await api.saveBillTemplate({ name: safeTemplateName, data }); + toast.success('Template saved'); + } onSave(); onClose(); } catch (err) { @@ -526,13 +557,28 @@ export default function BillModal({ bill, categories, onClose, onSave }) { setAutopay(e.target.checked)} + onChange={e => handleAutopayChange(e.target.checked)} className="h-4 w-4 rounded border-border accent-emerald-500" /> Autopay / Autodraft +