diff --git a/HISTORY.md b/HISTORY.md index bb1c363..72c27bb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,30 @@ # Bill Tracker β€” Changelog ## v0.41.0 +### πŸ”· TypeScript foundation + branded money types + +- **[Client] Adopted TypeScript incrementally, starting with the money boundary** β€” the move off plain JS. Upgraded `jsconfig` β†’ `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched β€” only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. +- **[Client] Branded `Cents`/`Dollars` types** β€” converted `client/lib/money.js` β†’ `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100Γ—-too-big bug β€” cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. +- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** β€” extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers β€” `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks β€” typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers β€” introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities β€” `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks β€” `useAutoSave.ts` (a generic `useAutoSave` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) β€” and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline). +- **[Client] Typed the API client (`client/api.js` β†’ `api.ts`) β€” the fetch layer's infrastructure** β€” the central request layer is now TypeScript: a generic `_fetch` and `get`/`post`/`put`/`patch`/`del` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally β€” most methods resolve to `Promise` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay` β†’ `PaymentRecord` (`id`), `togglePaid` β†’ `TogglePaidResult` (`paymentId?`), `settings` β†’ a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value β€” fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe. +- **[Client] Typed the React Query hooks (`hooks/useQueries.js` β†’ `.ts`) β€” and it caught dead config** β€” the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` β€” an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only β€” no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe. + +- **[Client] Phase A β€” typed the API response shapes with branded money** β€” new `client/types.ts` domain module: every money field branded (`Dollars` for bill/payment/tracker/summary amounts; **`Cents` for raw bank-transaction amounts, which stay cents on the wire** β€” the complement that keeps `formatCentsUSD` and `formatUSD` from being swapped). Reverse-engineered from the server serializers: `TrackerResponse` (summary/bank_tracking/cashflow/rows), `Bill`, `Payment`, `Category`, `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`, `BankTransaction`, `TimelineBill`. `api.tracker/bills/payments/categories/…` now return these, so the brand flows from the fetch layer into `trackerUtils` (row money upgraded from `number` β†’ `Dollars`; `paymentSummary` re-brands via `asDollars`). + +- **[Client] Phase B β€” converted every component + page `.jsx` β†’ `.tsx` (COMPLETE β€” `find client -name '*.jsx'` returns 0)** β€” done: **all 22 `ui/*` primitives** + `ThemeContext`; the **entire `components/tracker/` dir** (18 files β€” the home page) where `fmt(row.expected_amount)` now type-checks against `Dollars`; the **entire `components/bill-modal/` dir + `BillModal.tsx`** (the 1092-line add/edit form reached from 9 entry points); the **app shell** (`App`/`main`/`useAuth`/`Layout`/`Sidebar`/routing; `index.html` entry β†’ `main.tsx`); app-wide commons (`ErrorBoundary` class, `PageTransition`, `StatusBadge`, `MarkdownText`, `SummaryCard`, `ReleaseNotesDialog`); the `snowball/` + `transactions/` dirs; the **entire `components/admin/` dir** (11 cards incl. the OIDC/auth-methods form, bank-sync, backup manager); the **entire `components/data/` dir** (14 files β€” the Data page's SimpleFIN connect/sync workbench, transaction-matching, and the CSV / OFX / XLSX-spreadsheet / SQLite import flows, with `Cents`-branded bank-transaction amounts flowing through); and several pages. **A required infra fix:** Tailwind's `content` glob was `./client/**/*.{js,jsx}` β€” it never scanned `.tsx`, so any class used *only* in a converted file generated **no CSS** (the release-notes dialog's `left-[50%] top-[50%]` centering vanished β†’ modal rendered off-screen). typecheck/build/48-tests all passed; only the **e2e probe** caught it. Fixed to `{js,jsx,ts,tsx}`. **The final page batch** finished the job: the 5 largest/most-stateful pages β€” `SnowballPage`, `BankTransactionsPage` (the `Cents`-based bank ledger, with a local `Tx` structurally compatible with `BankTransaction` so it still satisfies `MatchBillDialog`), `SpendingPage` (a `RealCatEntry` narrowing splits the budget-indexed real categories from the `null`/`'other'` pseudo-rows), `BillsPage`, and `SubscriptionsPage` (1814 lines β€” `useOptimistic` reducer, the virtualizer's `FlatItem` discriminated union, and drag-reorder typed via the shared `DragProps`/`MoveControls`). Every page: local domain interfaces, React Query `setQueryData` for the optimistic caches, cast-at-boundary for `api.*` (`Promise`) results, and the local `fmt(v: number|null|undefined)` branded-`Dollars` wrapper so page money still type-checks. Verified: `npm run typecheck` (0) + `npm run lint` (0 errors) + `npm run build` + the 17/17 e2e probe (incl. axe on `/`, `/spending`, `/bills`, `/snowball`, `/summary`, `/analytics`, `/categories`, `/data`). + +### πŸ† What the TypeScript migration caught (real defects, not just types) + +- **The cents/dollars bug class is now unrepresentable** β€” the whole point of the branded `Dollars`/`Cents` types: a typed caller **physically cannot** format cents as dollars (the 100Γ—-too-big bug, cf. QA-B9-01) or pass a bank-transaction amount to `formatUSD`. A bare number at a `fmt()` boundary is a compile error. +- **Dead React Query v5 config** β€” three hooks passed `cacheTime`, silently ignored since the v5 `gcTime` rename; caches were GC'd at the 5-min default instead of the intended 30 min / 2 hr. Fixed. +- **A latent narrowing bug** β€” the un-pay Undo closure in `usePaymentActions` read `result.paymentId` inside a deferred async callback where TS correctly re-widens the `if`-narrowed value; captured the id in a const. +- **A dead prop** β€” `App` passed `mainContentId` to `TrackerPage`, which takes no params. Removed. +- **A `never`-typed context** β€” `createContext(null)` made `useContext`'s post-guard return `never`, so `setTheme` (and the auth context methods) were uncallable. Typed the context value. +- **A CSS build gap invisible to typecheck/build/tests** β€” the Tailwind `.tsx` glob omission above; the **e2e probe** was the only gate that caught it, reinforcing that a passing build β‰  runtime-safe for a `.tsx` rename touching many files. +- **Dead code with broken calls** β€” `PrivacyAdminCard` was imported nowhere yet called non-existent `api.privacySettings`/`setPrivacySettings` (the geolocation toggle actually lives on `/profile/settings`). Deleted rather than converted. +- **A missing import that crashed a live view** β€” `ImportTransactionCsvSection` rendered `` **6Γ—** (in the CSV preview + results panels) but never imported it. In the `.jsx` this is a `ReferenceError: CountPill is not defined` the moment you preview a CSV β€” a runtime crash `react/jsx-no-undef` wasn't configured to catch, but `tsc` flagged immediately as `TS2304: Cannot find name 'CountPill'`. Added the import. (Same file also carried two genuinely-unused imports β€” `CountPill`/`fmt` in the XLSX section β€” that TS surfaced and we dropped.) +- **A dead `total` state on the Spending page** β€” `IncomeSection` kept a `const [total, setTotal] = useState(0)` and dutifully `setTotal(d.total || 0)` on every income-page load, but nothing ever read `total` β€” a pointless render-triggering write. The `noUnusedLocals`-adjacent lint surfaced it during the `.tsx` conversion; removed the state and its setter. (The rest of the final 5-page batch type-checked clean on the first or second pass β€” a good signal the pages were already sound.) + ### πŸ§ͺ Money-service test coverage - **[Tests] Covered two untested money-critical services** β€” the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2) diff --git a/client/App.jsx b/client/App.tsx similarity index 97% rename from client/App.jsx rename to client/App.tsx index e78c76f..19ef91a 100644 --- a/client/App.jsx +++ b/client/App.tsx @@ -1,5 +1,4 @@ - -import { lazy, Suspense, useId } from 'react'; +import { lazy, Suspense, useId, type ReactNode } from 'react'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import Layout from '@/components/layout/Layout'; @@ -61,7 +60,7 @@ const PayoffPage = lazy(() => import('@/pages/PayoffPage')); const SpendingPage = lazy(() => import('@/pages/SpendingPage')); const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage')); -function RequireAuth({ children, role }) { +function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode { const { user, singleUserMode } = useAuth(); const location = useLocation(); @@ -96,7 +95,7 @@ function RequireAuth({ children, role }) { return children; } -function AdminShell({ children }) { +function AdminShell({ children }: { children: ReactNode }) { const location = useLocation(); return ( @@ -220,7 +219,7 @@ export default function App() { } > - }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/client/api.js b/client/api.js deleted file mode 100644 index ef4322f..0000000 --- a/client/api.js +++ /dev/null @@ -1,499 +0,0 @@ -// Fetch CSRF token from the server once and cache in memory. -// The cookie is httpOnly so document.cookie cannot access it directly. -let _csrfFetch = null; -async function getCsrfToken() { - if (!_csrfFetch) { - _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) - .then(r => r.json()) - .then(d => d.token || '') - .catch(() => { - _csrfFetch = null; // don't cache a failed fetch - return ''; - }); - } - return _csrfFetch; -} - -const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; - -// Parse a response body without assuming it is JSON. Returns null when the -// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). -async function parseJsonSafe(res) { - if (res.status === 204) return null; - const text = await res.text(); - if (!text) return null; - try { return JSON.parse(text); } catch { return null; } -} - -async function _fetch(method, path, body, _retried = false) { - const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' }; - // Add CSRF token header for state-changing methods - if (MUTATING_METHODS.includes(method)) { - const csrfToken = await getCsrfToken(); - if (csrfToken) { - opts.headers['x-csrf-token'] = csrfToken; - } - } - if (body !== undefined) opts.body = JSON.stringify(body); - const res = await fetch('/api' + path, opts); - const data = await parseJsonSafe(res); - if (!res.ok) { - // Stale CSRF token (cookie rotated/expired since first fetch): refresh the - // cached token and retry the request once instead of forcing a page reload. - if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) { - _csrfFetch = null; - return _fetch(method, path, body, true); - } - const err = new Error(data?.message || data?.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data || {}; - err.details = data?.details || []; - err.code = data?.code; - throw err; - } - return data ?? {}; -} - -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}` : ''; -} - -const get = (path, params) => _fetch('GET', path + (params ? queryString(params) : '')); -const post = (path, body) => _fetch('POST', path, body); -const put = (path, body) => _fetch('PUT', path, body); -const patch = (path, body) => _fetch('PATCH', path, body); -const del = (path) => _fetch('DELETE', path); - -function filenameFromDisposition(value) { - if (!value) return null; - const match = value.match(/filename="?([^"]+)"?/i); - return match ? match[1] : null; -} - -export const api = { - // Auth - me: () => get('/auth/me'), - authMode: () => get('/auth/mode'), - login: (data) => post('/auth/login', data), - logout: () => post('/auth/logout'), - restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), - changePassword: (data) => post('/auth/change-password', data), - acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), - acknowledgeVersion: () => post('/auth/acknowledge-version'), - loginHistory: () => get('/auth/login-history'), - // Spending - spendingSummary: (p) => get('/spending/summary', p), - spendingTransactions:(p) => get('/spending/transactions', p), - categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d), - spendingBudgets: (p) => get('/spending/budgets', p), - setSpendingBudget: (d) => put('/spending/budgets', d), - copySpendingBudgets: (d) => post('/spending/budgets/copy', d), - spendingIncome: (p) => get('/spending/income', p), - spendingCategoryRules: () => get('/spending/category-rules'), - addSpendingRule: (d) => post('/spending/category-rules', d), - deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`), - - totpStatus: () => get('/auth/totp/status'), - totpSetup: () => get('/auth/totp/setup'), - totpEnable: (data) => post('/auth/totp/enable', data), - totpDisable: (data) => post('/auth/totp/disable', data), - totpChallenge: (data) => post('/auth/totp/challenge', data), - - webauthnStatus: () => get('/auth/webauthn/status'), - webauthnSetup: () => get('/auth/webauthn/setup'), - webauthnEnable: (data) => post('/auth/webauthn/enable', data), - webauthnDisable: (data) => post('/auth/webauthn/disable', data), - webauthnCredentials: () => get('/auth/webauthn/credentials'), - webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), - webauthnChallenge: (data) => post('/auth/webauthn/challenge', data), - - // Admin - hasUsers: () => get('/admin/has-users'), - adminUsers: () => get('/admin/users'), - createUser: (data) => post('/admin/users', data), - resetPassword: (id, data) => put(`/admin/users/${id}/password`, data), - updateUserRole: (id, data) => put(`/admin/users/${id}/role`, data), - updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data), - deleteUser: (id) => del(`/admin/users/${id}`), - authModeConfig: () => get('/admin/auth-mode'), - setAuthMode: (data) => put('/admin/auth-mode', data), - testOidcConfig: (data) => post('/admin/auth-mode/oidc-test', data), - adminBackups: () => get('/admin/backups'), - createAdminBackup: () => post('/admin/backups'), - deleteAdminBackup: (id) => del(`/admin/backups/${encodeURIComponent(id)}`), - restoreAdminBackup: (id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`), - adminBackupSettings: () => get('/admin/backups/settings'), - saveAdminBackupSettings: (data) => put('/admin/backups/settings', data), - runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'), - adminCleanup: () => get('/admin/cleanup'), - saveAdminCleanup: (data) => put('/admin/cleanup', data), - runAdminCleanup: () => post('/admin/cleanup/run'), - seedDemoData: () => post('/user/seed-demo-data'), - clearDemoData: () => post('/user/clear-demo-data'), - seededStatus: () => get('/user/seeded-status'), - eraseMyData: (confirm) => post('/user/erase-data', { confirm }), - downloadAdminBackup: async (id) => { - const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { - credentials: 'include', - }); - if (!res.ok) { - let data = {}; - try { data = await res.json(); } catch {} - throw new Error(data.error || `HTTP ${res.status}`); - } - return { - blob: await res.blob(), - filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || id, - }; - }, - importAdminBackup: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/admin/backups/import', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - - // Notifications (admin) - notifAdmin: () => get('/notifications/admin'), - saveNotifAdmin: (data) => put('/notifications/admin', data), - testEmail: (data) => post('/notifications/test', data), - testPushNotification: () => post('/notifications/test-push', {}), - notifMe: () => get('/notifications/me'), - saveNotifMe: (data) => put('/notifications/me', data), - - // Profile - profile: () => get('/profile'), - updateProfile: (data) => _fetch('PATCH', '/profile', data), - profileSettings: () => get('/profile/settings'), - updateProfileSettings: (data) => _fetch('PATCH', '/profile/settings', data), - changeProfilePassword: (data) => post('/profile/change-password', data), - profileExports: () => get('/profile/exports'), - profileImportHistory: () => get('/profile/import-history'), - - // Tracker - tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), - upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), - overdueCount: () => get('/tracker/overdue-count'), - snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data), - - // Calendar - calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), - calendarFeed: () => get('/calendar/feed'), - createCalendarFeed: () => post('/calendar/feed', {}), - regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), - revokeCalendarFeed: () => del('/calendar/feed'), - calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), - - // Summary - summary: (y, m) => get(`/summary?year=${y}&month=${m}`), - saveSummaryIncome: (data) => put('/summary/income', data), - getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`), - updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data), - - // Bills - bills: (params = {}) => get(`/bills${queryString(params)}`), - allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), - deletedBills: () => get('/bills/deleted'), - billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), - bill: (id) => get(`/bills/${id}`), - createBill: (data) => post('/bills', data), - updateBill: (id, data) => put(`/bills/${id}`, data), - reorderBills: (order) => put('/bills/reorder', order), - archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }), - updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), - billAmortization: (id, opts = {}) => { - const params = new URLSearchParams(); - if (opts.payment) params.set('payment', String(opts.payment)); - if (opts.max_months) params.set('max_months', String(opts.max_months)); - const qs = params.toString(); - return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`); - }, - 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), - verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}), - togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), - billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), - billTransactions: (id) => get(`/bills/${id}/transactions`), - syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`), - allBillMerchantRules: () => get('/bills/merchant-rules'), - billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`), - previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), - addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }), - deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`), - merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`), - toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }), - importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }), - billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), - saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data), - billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`), - 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}`), - driftReport: () => get('/bills/drift-report'), - snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}), - billTemplates: () => get('/bills/templates'), - saveBillTemplate: (data) => post('/bills/templates', data), - deleteBillTemplate: (id) => del(`/bills/templates/${id}`), - - // Subscriptions - subscriptions: () => get('/subscriptions'), - confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), - matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', { - transaction_ids: transactionIds, - bill_id: billId, - merchant, - catalog_id: catalogId, - confidence, - }), - subscriptionRecommendations: () => get('/subscriptions/recommendations'), - subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), - updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), - createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), - declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', { - decline_key: recommendation?.decline_key || recommendation, - catalog_id: recommendation?.catalog_match?.id, - merchant: recommendation?.merchant, - confidence: recommendation?.confidence, - }), - subscriptionCatalog: () => get('/subscriptions/catalog'), - updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }), - addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }), - deleteCatalogDescriptor: (id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${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), - deletePayment: (id) => del(`/payments/${id}`), - restorePayment: (id) => post(`/payments/${id}/restore`), - recentAutoMatched: () => get('/payments/recent-auto'), - undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`), - - // Snowball - snowball: () => get('/snowball'), - snowballSettings: () => get('/snowball/settings'), - saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), - saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), - snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`), - snowballPlans: () => get('/snowball/plans'), - snowballActivePlan: () => get('/snowball/plans/active'), - startSnowballPlan: (data) => post('/snowball/plans', data), - updateSnowballPlan: (id, d) => _fetch('PATCH', `/snowball/plans/${id}`, d), - pauseSnowballPlan: (id) => post(`/snowball/plans/${id}/pause`, {}), - resumeSnowballPlan: (id) => post(`/snowball/plans/${id}/resume`, {}), - completeSnowballPlan: (id) => post(`/snowball/plans/${id}/complete`, {}), - abandonSnowballPlan: (id) => post(`/snowball/plans/${id}/abandon`, {}), - - // Categories - categories: () => get('/categories'), - createCategory: (data) => post('/categories', data), - reorderCategories: (order) => put('/categories/reorder', order), - updateCategory: (id, data) => put(`/categories/${id}`, data), - toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }), - deleteCategory: (id) => del(`/categories/${id}`), - restoreCategory: (id) => post(`/categories/${id}/restore`), - - // Category groups - categoryGroups: () => get('/categories/groups'), - createCategoryGroup: (data) => post('/categories/groups', data), - updateCategoryGroup: (id, data) => put(`/categories/groups/${id}`, data), - deleteCategoryGroup: (id) => del(`/categories/groups/${id}`), - - // Settings - settings: () => get('/settings'), - saveSettings: (data) => put('/settings', data), - - // Analytics - analyticsSummary: (params = {}) => { - const qs = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); - }); - const query = qs.toString(); - return get(`/analytics/summary${query ? `?${query}` : ''}`); - }, - - // Status - status: () => get('/status'), - - // Version (public) - about: () => get('/about'), - privacy: () => get('/privacy'), - aboutAdmin: () => get('/about-admin'), - roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), - updateStatus: () => get('/version/update-status'), - checkForUpdates: () => post('/about-admin/check-updates'), - getUpdateCheckSetting: () => get('/about-admin/update-check-setting'), - setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }), - devLog: () => get('/about-admin/dev-log'), - version: () => get('/version'), - releaseHistory: () => get('/version/history'), - - // Export (returns a URL to navigate to, not a fetch) - exportUrl: (year, fmt) => `/api/export?year=${year}&format=${fmt||'csv'}`, - - // Spreadsheet Import - previewSpreadsheetImport: async (file, options = {}) => { - const params = new URLSearchParams(); - if (options.parseAllSheets) params.set('parse_all_sheets', 'true'); - if (options.defaultYear) params.set('year', String(options.defaultYear)); - if (options.defaultMonth) params.set('month', String(options.defaultMonth)); - const qs = params.toString(); - const csrfToken = await getCsrfToken(); - const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/octet-stream', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data), - previewCsvTransactionImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/csv/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'text/csv', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - commitCsvTransactionImport: (data) => post('/import/csv/commit', data), - previewOfxTransactionImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/ofx/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/x-ofx', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - commitOfxTransactionImport: (data) => post('/import/ofx/commit', data), - importHistory: () => get('/import/history'), - - // Transactions - transactions: (params = {}) => get(`/transactions${queryString(params)}`), - bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`), - createManualTransaction: (data) => post('/transactions/manual', data), - updateTransaction: (id, data) => put(`/transactions/${id}`, data), - deleteTransaction: (id) => del(`/transactions/${id}`), - matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }), - unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`), - unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }), - ignoreTransaction: (id) => post(`/transactions/${id}/ignore`), - unignoreTransaction: (id) => post(`/transactions/${id}/unignore`), - transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`), - applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`), - autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts), - - // Match suggestions - matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`), - rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`), - - // Data sources & SimpleFIN bank sync - dataSources: (params = {}) => get(`/data-sources${queryString(params)}`), - simplefinStatus: () => get('/data-sources/simplefin/status'), - connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }), - syncDataSource: (id) => post(`/data-sources/${id}/sync`), - backfillDataSource: (id) => post(`/data-sources/${id}/backfill`), - deleteDataSource: (id) => del(`/data-sources/${id}`), - dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), - setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), - allFinancialAccounts: () => get('/data-sources/accounts/all'), - syncAllSources: () => post('/data-sources/sync-all', {}), - attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), - - // Admin β€” bank sync feature flag - bankSyncConfig: () => get('/admin/bank-sync-config'), - setBankSyncConfig: (data) => put('/admin/bank-sync-config', data), - - // User SQLite import - previewUserDbImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/user-db/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/octet-stream', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - applyUserDbImport: (data) => post('/import/user-db/apply', data), -}; diff --git a/client/api.ts b/client/api.ts new file mode 100644 index 0000000..d35a734 --- /dev/null +++ b/client/api.ts @@ -0,0 +1,523 @@ +import type { Bill, Category, Payment, TrackerResponse } from '@/types'; + +// Fetch CSRF token from the server once and cache in memory. +// The cookie is httpOnly so document.cookie cannot access it directly. +let _csrfFetch: Promise | null = null; +async function getCsrfToken(): Promise { + if (!_csrfFetch) { + _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) + .then(r => r.json()) + .then(d => d.token || '') + .catch(() => { + _csrfFetch = null; // don't cache a failed fetch + return ''; + }); + } + return _csrfFetch; +} + +// Common parameter/body shapes for the client. Endpoint responses are typed +// incrementally β€” most methods currently resolve to `unknown` (callers narrow), +// with the money-critical/consumed ones given real shapes below. +type Id = number | string; +type Body = unknown; +export type QueryParams = Record; + +/** Error thrown by the API client, carrying the server's structured fields. */ +export interface ApiError extends Error { + status?: number; + data?: unknown; + details?: unknown[]; + code?: string; +} + +/** Result of toggling a tracker row paid/unpaid. */ +export interface TogglePaidResult { + paymentId?: number; + [key: string]: unknown; +} + +const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; + +// Parse a response body without assuming it is JSON. Returns null when the +// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). +async function parseJsonSafe(res: Response): Promise { + if (res.status === 204) return null; + const text = await res.text(); + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} + +async function _fetch(method: string, path: string, body?: unknown, _retried = false): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + const opts: RequestInit = { method, headers, credentials: 'include' }; + // Add CSRF token header for state-changing methods + if (MUTATING_METHODS.includes(method)) { + const csrfToken = await getCsrfToken(); + if (csrfToken) { + headers['x-csrf-token'] = csrfToken; + } + } + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch('/api' + path, opts); + const data = await parseJsonSafe(res); + if (!res.ok) { + // Stale CSRF token (cookie rotated/expired since first fetch): refresh the + // cached token and retry the request once instead of forcing a page reload. + if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) { + _csrfFetch = null; + return _fetch(method, path, body, true); + } + const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data || {}; + err.details = data?.details || []; + err.code = data?.code; + throw err; + } + return (data ?? {}) as T; +} + +function queryString(params: QueryParams = {}): string { + 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}` : ''; +} + +const get = (path: string, params?: QueryParams): Promise => _fetch('GET', path + (params ? queryString(params) : '')); +const post = (path: string, body?: unknown): Promise => _fetch('POST', path, body); +const put = (path: string, body?: unknown): Promise => _fetch('PUT', path, body); +const patch = (path: string, body?: unknown): Promise => _fetch('PATCH', path, body); +const del = (path: string): Promise => _fetch('DELETE', path); + +function filenameFromDisposition(value: string | null): string | null { + if (!value) return null; + const match = value.match(/filename="?([^"]+)"?/i); + return match ? (match[1] ?? null) : null; +} + +export const api = { + // Auth + me: () => get('/auth/me'), + authMode: () => get('/auth/mode'), + login: (data: Body) => post('/auth/login', data), + logout: () => post('/auth/logout'), + restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), + changePassword: (data: Body) => post('/auth/change-password', data), + acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgeVersion: () => post('/auth/acknowledge-version'), + loginHistory: () => get('/auth/login-history'), + // Spending + spendingSummary: (p?: QueryParams) => get('/spending/summary', p), + spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p), + categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d), + spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p), + setSpendingBudget: (d: Body) => put('/spending/budgets', d), + copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d), + spendingIncome: (p?: QueryParams) => get('/spending/income', p), + spendingCategoryRules: () => get('/spending/category-rules'), + addSpendingRule: (d: Body) => post('/spending/category-rules', d), + deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`), + + totpStatus: () => get('/auth/totp/status'), + totpSetup: () => get('/auth/totp/setup'), + totpEnable: (data: Body) => post('/auth/totp/enable', data), + totpDisable: (data: Body) => post('/auth/totp/disable', data), + totpChallenge: (data: Body) => post('/auth/totp/challenge', data), + + webauthnStatus: () => get('/auth/webauthn/status'), + webauthnSetup: () => get('/auth/webauthn/setup'), + webauthnEnable: (data: Body) => post('/auth/webauthn/enable', data), + webauthnDisable: (data: Body) => post('/auth/webauthn/disable', data), + webauthnCredentials: () => get('/auth/webauthn/credentials'), + webauthnDeleteCred: (id: Id, data: Body) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), + webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data), + + // Admin + hasUsers: () => get('/admin/has-users'), + adminUsers: () => get('/admin/users'), + createUser: (data: Body) => post('/admin/users', data), + resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data), + updateUserRole: (id: Id, data: Body) => put(`/admin/users/${id}/role`, data), + updateUserActive: (id: Id, data: Body) => put(`/admin/users/${id}/active`, data), + deleteUser: (id: Id) => del(`/admin/users/${id}`), + authModeConfig: () => get('/admin/auth-mode'), + setAuthMode: (data: Body) => put('/admin/auth-mode', data), + testOidcConfig: (data: Body) => post('/admin/auth-mode/oidc-test', data), + adminBackups: () => get('/admin/backups'), + createAdminBackup: () => post('/admin/backups'), + deleteAdminBackup: (id: Id) => del(`/admin/backups/${encodeURIComponent(id)}`), + restoreAdminBackup: (id: Id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`), + adminBackupSettings: () => get('/admin/backups/settings'), + saveAdminBackupSettings: (data: Body) => put('/admin/backups/settings', data), + runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'), + adminCleanup: () => get('/admin/cleanup'), + saveAdminCleanup: (data: Body) => put('/admin/cleanup', data), + runAdminCleanup: () => post('/admin/cleanup/run'), + seedDemoData: () => post('/user/seed-demo-data'), + clearDemoData: () => post('/user/clear-demo-data'), + seededStatus: () => get('/user/seeded-status'), + eraseMyData: (confirm: unknown) => post('/user/erase-data', { confirm }), + downloadAdminBackup: async (id: Id) => { + const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { + credentials: 'include', + }); + if (!res.ok) { + let data: any = {}; + try { data = await res.json(); } catch {} + throw new Error(data.error || `HTTP ${res.status}`); + } + return { + blob: await res.blob(), + filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || String(id), + }; + }, + importAdminBackup: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/admin/backups/import', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + + // Notifications (admin) + notifAdmin: () => get('/notifications/admin'), + saveNotifAdmin: (data: Body) => put('/notifications/admin', data), + testEmail: (data: Body) => post('/notifications/test', data), + testPushNotification: () => post('/notifications/test-push', {}), + notifMe: () => get('/notifications/me'), + saveNotifMe: (data: Body) => put('/notifications/me', data), + + // Profile + profile: () => get('/profile'), + updateProfile: (data: Body) => _fetch('PATCH', '/profile', data), + profileSettings: () => get('/profile/settings'), + updateProfileSettings: (data: Body) => _fetch('PATCH', '/profile/settings', data), + changeProfilePassword: (data: Body) => post('/profile/change-password', data), + profileExports: () => get('/profile/exports'), + profileImportHistory: () => get('/profile/import-history'), + + // Tracker + tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), + upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), + overdueCount: () => get('/tracker/overdue-count'), + snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), + + // Calendar + calendar: (y: number, m: number) => get(`/calendar?year=${y}&month=${m}`), + calendarFeed: () => get('/calendar/feed'), + createCalendarFeed: () => post('/calendar/feed', {}), + regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), + revokeCalendarFeed: () => del('/calendar/feed'), + calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), + + // Summary + summary: (y: number, m: number) => get(`/summary?year=${y}&month=${m}`), + saveSummaryIncome: (data: Body) => put('/summary/income', data), + getMonthlyStartingAmounts: (y: number, m: number) => get(`/monthly-starting-amounts?year=${y}&month=${m}`), + updateMonthlyStartingAmounts: (data: Body) => put('/monthly-starting-amounts', data), + + // Bills + bills: (params: QueryParams = {}) => get(`/bills${queryString(params)}`), + allBills: (params: QueryParams = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), + billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), + bill: (id: Id) => get(`/bills/${id}`), + createBill: (data: Body) => post('/bills', data), + updateBill: (id: Id, data: Body) => put(`/bills/${id}`, data), + reorderBills: (order: Body) => put('/bills/reorder', order), + archiveBill: (id: Id, archived = true) => put(`/bills/${id}/archived`, { archived }), + updateBillBalance: (id: Id, bal: unknown) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), + billAmortization: (id: Id, opts: { payment?: number | string; max_months?: number | string } = {}) => { + const params = new URLSearchParams(); + if (opts.payment) params.set('payment', String(opts.payment)); + if (opts.max_months) params.set('max_months', String(opts.max_months)); + const qs = params.toString(); + return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`); + }, + updateBillSnowball: (id: Id, data: Body) => _fetch('PATCH', `/bills/${id}/snowball`, data), + deleteBill: (id: Id) => del(`/bills/${id}`), + restoreBill: (id: Id) => post(`/bills/${id}/restore`), + duplicateBill: (id: Id, data: Body) => post(`/bills/${id}/duplicate`, data), + verifyAutopay: (id: Id) => post(`/bills/${id}/verify-autopay`, {}), + togglePaid: (id: Id, data: Body) => post(`/bills/${id}/toggle-paid`, data), + billPayments: (id: Id, p?: number, l?: number) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), + billTransactions: (id: Id) => get(`/bills/${id}/transactions`), + syncBillSimplefinPayments: (id: Id) => post(`/bills/${id}/sync-simplefin-payments`), + allBillMerchantRules: () => get('/bills/merchant-rules'), + billMerchantRules: (id: Id) => get(`/bills/${id}/merchant-rules`), + previewMerchantRule: (id: Id, merchant: string) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), + addMerchantRule: (id: Id, merchant: string) => post(`/bills/${id}/merchant-rules`, { merchant }), + deleteMerchantRule: (id: Id, ruleId: Id) => del(`/bills/${id}/merchant-rules/${ruleId}`), + merchantRuleCandidates: (id: Id) => get(`/bills/${id}/merchant-rules/candidates`), + toggleRuleAutoAttribute: (id: Id, ruleId: Id, on: unknown) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }), + importHistoricalPayments: (id: Id, ids: unknown) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }), + billMonthlyState: (id: Id, y: number, m: number) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), + saveBillMonthlyState: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), + billHistoryRanges: (id: Id) => get(`/bills/${id}/history-ranges`), + createBillHistoryRange: (id: Id, data: Body) => post(`/bills/${id}/history-ranges`, data), + updateBillHistoryRange: (id: Id, rangeId: Id, data: Body) => put(`/bills/${id}/history-ranges/${rangeId}`, data), + deleteBillHistoryRange: (id: Id, rangeId: Id) => del(`/bills/${id}/history-ranges/${rangeId}`), + driftReport: () => get('/bills/drift-report'), + snoozeBillDrift: (id: Id) => post(`/bills/${id}/snooze-drift`, {}), + billTemplates: () => get('/bills/templates'), + saveBillTemplate: (data: Body) => post('/bills/templates', data), + deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`), + + // Subscriptions + subscriptions: () => get('/subscriptions'), + confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), + matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', { + transaction_ids: transactionIds, + bill_id: billId, + merchant, + catalog_id: catalogId, + confidence, + }), + subscriptionRecommendations: () => get('/subscriptions/recommendations'), + subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), + updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data), + createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data), + declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', { + decline_key: recommendation?.decline_key || recommendation, + catalog_id: recommendation?.catalog_match?.id, + merchant: recommendation?.merchant, + confidence: recommendation?.confidence, + }), + subscriptionCatalog: () => get('/subscriptions/catalog'), + updateSubscriptionCatalogLink:(id: Id, catalogId: Id) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }), + addCatalogDescriptor: (catalogId: Id, d: unknown) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }), + deleteCatalogDescriptor: (id: Id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`), + + // Payments + quickPay: (data: Body) => post('/payments/quick', data), + confirmAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), + dismissAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), + bulkPay: (items: Body) => post('/payments/bulk', items), + createPayment: (data: Body) => post('/payments', data), + updatePayment: (id: Id, data: Body) => put(`/payments/${id}`, data), + deletePayment: (id: Id) => del(`/payments/${id}`), + restorePayment: (id: Id) => post(`/payments/${id}/restore`), + recentAutoMatched: () => get('/payments/recent-auto'), + undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`), + + // Snowball + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data), + saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items), + snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`), + snowballPlans: () => get('/snowball/plans'), + snowballActivePlan: () => get('/snowball/plans/active'), + startSnowballPlan: (data: Body) => post('/snowball/plans', data), + updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d), + pauseSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/pause`, {}), + resumeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/resume`, {}), + completeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/complete`, {}), + abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}), + + // Categories + categories: () => get('/categories'), + createCategory: (data: Body) => post('/categories', data), + reorderCategories: (order: Body) => put('/categories/reorder', order), + updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data), + toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }), + deleteCategory: (id: Id) => del(`/categories/${id}`), + restoreCategory: (id: Id) => post(`/categories/${id}/restore`), + + // Category groups + categoryGroups: () => get('/categories/groups'), + createCategoryGroup: (data: Body) => post('/categories/groups', data), + updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data), + deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`), + + // Settings + settings: () => get>('/settings'), + saveSettings: (data: Body) => put('/settings', data), + + // Analytics + analyticsSummary: (params: QueryParams = {}) => { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); + }); + const query = qs.toString(); + return get(`/analytics/summary${query ? `?${query}` : ''}`); + }, + + // Status + status: () => get('/status'), + + // Version (public) + about: () => get('/about'), + privacy: () => get('/privacy'), + aboutAdmin: () => get('/about-admin'), + roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), + updateStatus: () => get('/version/update-status'), + checkForUpdates: () => post('/about-admin/check-updates'), + getUpdateCheckSetting: () => get('/about-admin/update-check-setting'), + setUpdateCheckSetting: (enabled: unknown) => put('/about-admin/update-check-setting', { enabled }), + devLog: () => get('/about-admin/dev-log'), + version: () => get('/version'), + releaseHistory: () => get('/version/history'), + + // Export (returns a URL to navigate to, not a fetch) + exportUrl: (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`, + + // Spreadsheet Import + previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => { + const params = new URLSearchParams(); + if (options.parseAllSheets) params.set('parse_all_sheets', 'true'); + if (options.defaultYear) params.set('year', String(options.defaultYear)); + if (options.defaultMonth) params.set('month', String(options.defaultMonth)); + const qs = params.toString(); + const csrfToken = await getCsrfToken(); + const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + applySpreadsheetImport: (data: Body) => post('/import/spreadsheet/apply', data), + previewCsvTransactionImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/csv/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'text/csv', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitCsvTransactionImport: (data: Body) => post('/import/csv/commit', data), + previewOfxTransactionImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/ofx/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-ofx', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitOfxTransactionImport: (data: Body) => post('/import/ofx/commit', data), + importHistory: () => get('/import/history'), + + // Transactions + transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`), + bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`), + createManualTransaction: (data: Body) => post('/transactions/manual', data), + updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data), + deleteTransaction: (id: Id) => del(`/transactions/${id}`), + matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }), + unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`), + unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }), + ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`), + unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`), + transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`), + applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`), + autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts), + + // Match suggestions + matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`), + rejectMatchSuggestion: (id: Id) => post(`/matches/${encodeURIComponent(id)}/reject`), + + // Data sources & SimpleFIN bank sync + dataSources: (params: QueryParams = {}) => get(`/data-sources${queryString(params)}`), + simplefinStatus: () => get('/data-sources/simplefin/status'), + connectSimplefin: (setupToken: unknown) => post('/data-sources/simplefin/connect', { setupToken }), + syncDataSource: (id: Id) => post(`/data-sources/${id}/sync`), + backfillDataSource: (id: Id) => post(`/data-sources/${id}/backfill`), + deleteDataSource: (id: Id) => del(`/data-sources/${id}`), + dataSourceAccounts: (sourceId: Id) => get(`/data-sources/${sourceId}/accounts`), + setAccountMonitored: (sourceId: Id, accountId: Id, monitored: unknown) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), + allFinancialAccounts: () => get('/data-sources/accounts/all'), + syncAllSources: () => post('/data-sources/sync-all', {}), + attributePaymentToMonth: (id: Id, paid_date: unknown) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), + + // Admin β€” bank sync feature flag + bankSyncConfig: () => get('/admin/bank-sync-config'), + setBankSyncConfig: (data: Body) => put('/admin/bank-sync-config', data), + + // User SQLite import + previewUserDbImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/user-db/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + applyUserDbImport: (data: Body) => post('/import/user-db/apply', data), +}; diff --git a/client/components/BillHistoricalImportDialog.jsx b/client/components/BillHistoricalImportDialog.tsx similarity index 83% rename from client/components/BillHistoricalImportDialog.jsx rename to client/components/BillHistoricalImportDialog.tsx index 8e97db3..bac61dc 100644 --- a/client/components/BillHistoricalImportDialog.jsx +++ b/client/components/BillHistoricalImportDialog.tsx @@ -1,24 +1,40 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, type ComponentType } from 'react'; import { toast } from 'sonner'; import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react'; import { api } from '@/api'; -import { cn, fmt, fmtDate } from '@/lib/utils'; +import { cn, fmt, fmtDate, errMessage } from '@/lib/utils'; +import type { Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; -const STATUS_META = { - unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null }, - matched_this_bill:{ label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, - matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle }, - payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, -}; +interface StatusMeta { + label: string | null; + className: string; + icon: ComponentType<{ className?: string }> | null; +} -function StatusChip({ candidate }) { - const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched; +interface Candidate { + id: number | string; + status?: string; + payee?: string | null; + paid_date?: string | null; + amount: Dollars; + matched_bill_name?: string | null; +} + +const STATUS_META = { + unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null }, + matched_this_bill: { label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, + matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle }, + payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, +} satisfies Record; + +function StatusChip({ candidate }: { candidate: Candidate }) { + const meta: StatusMeta = STATUS_META[candidate.status as keyof typeof STATUS_META] ?? STATUS_META.unmatched; const Icon = meta.icon; const label = candidate.status === 'matched_other_bill' ? `Matched to ${candidate.matched_bill_name || 'another bill'}` @@ -34,11 +50,19 @@ function StatusChip({ candidate }) { // ── Main dialog ─────────────────────────────────────────────────────────────── -export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) { - const [step, setStep] = useState('choice'); // 'choice' | 'pick' - const [candidates, setCandidates] = useState([]); +interface BillHistoricalImportDialogProps { + billId: number | string; + billName: string; + open: boolean; + onClose: () => void; + onImported?: (result: { imported?: number }) => void; +} + +export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }: BillHistoricalImportDialogProps) { + const [step, setStep] = useState<'choice' | 'pick'>('choice'); + const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(true); - const [selected, setSelected] = useState(new Set()); + const [selected, setSelected] = useState>(new Set()); const [importing, setImporting] = useState(false); // Load candidates whenever the dialog opens @@ -48,11 +72,12 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC setSelected(new Set()); setLoading(true); api.merchantRuleCandidates(billId) - .then(data => { + .then(raw => { + const data = raw as { candidates?: Candidate[] }; // Pre-select importable candidates (not already a payment for this bill) - const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); + const importableList = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); setCandidates(data.candidates || []); - setSelected(new Set(importable.map(c => c.id))); + setSelected(new Set(importableList.map(c => c.id))); }) .catch(() => setCandidates([])) .finally(() => setLoading(false)); @@ -61,29 +86,30 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill'); - async function doImport(ids) { + async function doImport(ids: (number | string)[]) { if (ids.length === 0) { onClose(); return; } setImporting(true); try { - const result = await api.importHistoricalPayments(billId, ids); + const result = await api.importHistoricalPayments(billId, ids) as { imported?: number }; toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`); onImported?.(result); onClose(); } catch (err) { - toast.error(err.message || 'Import failed'); + toast.error(errMessage(err, 'Import failed')); } finally { setImporting(false); } } - function toggleAll(checked) { + function toggleAll(checked: boolean) { setSelected(checked ? new Set(importable.map(c => c.id)) : new Set()); } - function toggle(id) { + function toggle(id: number | string) { setSelected(prev => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); } diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.tsx similarity index 82% rename from client/components/BillMerchantRules.jsx rename to client/components/BillMerchantRules.tsx index cfb0372..3c54bb0 100644 --- a/client/components/BillMerchantRules.jsx +++ b/client/components/BillMerchantRules.tsx @@ -3,16 +3,47 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; import { - AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X, + AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, X, } from 'lucide-react'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; +import { asDollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; +interface MerchantRule { + id: number | string; + merchant?: string; + auto_attribute_late?: number; +} + +interface Conflict { + id: number | string; + name?: string; +} + +interface Suggestion { + id: number | string; + label?: string; + normalized?: string; + amount?: number; + date?: string; +} + +interface SubTx { + id: number | string; + payee?: string | null; + description?: string | null; + memo?: string | null; + merchant?: string | null; + amount?: number; + posted_date?: string | null; + match_status?: string | null; +} + // Debounce helper -function useDebounce(value, delay) { +function useDebounce(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); @@ -21,7 +52,13 @@ function useDebounce(value, delay) { return debounced; } -function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) { +function RuleChip({ rule, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }: { + rule: MerchantRule; + onDelete: (rule: MerchantRule) => void; + onToggleAutoAttr: (rule: MerchantRule, enabled: boolean) => void; + deleting: number | string | null; + togglingAutoAttr: number | string | null; +}) { return (
@@ -65,7 +102,7 @@ function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, toggling ); } -function ConflictWarning({ conflicts }) { +function ConflictWarning({ conflicts }: { conflicts?: Conflict[] }) { if (!conflicts?.length) return null; return (
@@ -84,7 +121,7 @@ function ConflictWarning({ conflicts }) { ); } -function PreviewBadge({ count, loading, error }) { +function PreviewBadge({ count, loading, error }: { count: number | null; loading: boolean; error: boolean }) { if (loading) return ; if (error) return Error; if (count === null) return null; @@ -100,24 +137,28 @@ function PreviewBadge({ count, loading, error }) { ); } -export default function BillMerchantRules({ billId, billName, onRulesChanged }) { - const [rules, setRules] = useState([]); - const [suggestions, setSuggestions] = useState([]); +export default function BillMerchantRules({ billId, billName, onRulesChanged }: { + billId: number | string; + billName?: string; + onRulesChanged?: () => void; +}) { + const [rules, setRules] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); - const [deleting, setDeleting] = useState(null); + const [deleting, setDeleting] = useState(null); const [adding, setAdding] = useState(false); const [input, setInput] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); - const [previewCount, setPreviewCount] = useState(null); + const [previewCount, setPreviewCount] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(false); - const [conflicts, setConflicts] = useState([]); - const [retroFeedback, setRetroFeedback] = useState(null); + const [conflicts, setConflicts] = useState([]); + const [retroFeedback, setRetroFeedback] = useState(null); const [showHistoricalDialog, setShowHistoricalDialog] = useState(false); - const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); - const [liveResults, setLiveResults] = useState([]); + const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); + const [liveResults, setLiveResults] = useState([]); const [liveSearching, setLiveSearching] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef(null); const debouncedInput = useDebounce(input.trim(), 380); @@ -125,7 +166,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) if (!billId) return; setLoading(true); try { - const data = await api.billMerchantRules(billId); + const data = await api.billMerchantRules(billId) as { rules?: MerchantRule[]; suggestions?: Suggestion[] }; setRules(data.rules || []); setSuggestions(data.suggestions || []); } catch { @@ -148,9 +189,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) setPreviewLoading(true); setPreviewError(false); api.previewMerchantRule(billId, debouncedInput) - .then(data => { + .then(raw => { if (cancelled) return; - setPreviewCount(data.match_count); + const data = raw as { match_count?: number; conflicts?: Conflict[] }; + setPreviewCount(data.match_count ?? null); setConflicts(data.conflicts || []); }) .catch(() => { @@ -169,9 +211,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) let cancelled = false; setLiveSearching(true); api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 }) - .then(data => { + .then(raw => { if (cancelled) return; - const rows = Array.isArray(data) ? data : (data?.transactions ?? []); + const data = raw as SubTx[] | { transactions?: SubTx[] }; + const rows: SubTx[] = Array.isArray(data) ? data : (data?.transactions ?? []); setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({ id: tx.id, label: tx.payee || tx.description || tx.memo || '', @@ -187,16 +230,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) // Popover handles its own outside-click dismissal β€” no manual handler needed - async function handleAdd(merchantText) { + async function handleAdd(merchantText?: string) { const text = (merchantText || input).trim(); if (!text) return; setAdding(true); setRetroFeedback(null); try { - const result = await api.addMerchantRule(billId, text); + const result = await api.addMerchantRule(billId, text) as { rule?: MerchantRule }; setRules(prev => { if (prev.some(r => r.id === result.rule?.id)) return prev; - return [...prev, result.rule].filter(Boolean); + return [...prev, result.rule].filter(Boolean) as MerchantRule[]; }); setInput(''); setPreviewCount(null); @@ -207,13 +250,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) setShowHistoricalDialog(true); onRulesChanged?.(); } catch (err) { - toast.error(err.message || 'Failed to add rule'); + toast.error(errMessage(err, 'Failed to add rule')); } finally { setAdding(false); } } - async function handleDelete(rule) { + async function handleDelete(rule: MerchantRule) { setDeleting(rule.id); try { await api.deleteMerchantRule(billId, rule.id); @@ -221,13 +264,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) toast.success(`Rule "${rule.merchant}" removed`); onRulesChanged?.(); } catch (err) { - toast.error(err.message || 'Failed to remove rule'); + toast.error(errMessage(err, 'Failed to remove rule')); } finally { setDeleting(null); } } - async function handleToggleAutoAttr(rule, enabled) { + async function handleToggleAutoAttr(rule: MerchantRule, enabled: boolean) { setTogglingAutoAttr(rule.id); try { await api.toggleRuleAutoAttribute(billId, rule.id, enabled); @@ -236,14 +279,14 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) ? `Auto-fix on β€” ${rule.merchant} payments will automatically count for the prior month` : 'Auto-fix off'); } catch (err) { - toast.error(err.message || 'Failed to update rule'); + toast.error(errMessage(err, 'Failed to update rule')); } finally { setTogglingAutoAttr(null); } } - function pickSuggestion(s) { - setInput(s.label); + function pickSuggestion(s: Suggestion) { + setInput(s.label || ''); setShowSuggestions(false); inputRef.current?.focus(); } @@ -272,7 +315,6 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) {s.label} - {fmt(amountVal)} + {fmt(asDollars(amountVal))} ); diff --git a/client/components/BillModal.jsx b/client/components/BillModal.tsx similarity index 88% rename from client/components/BillModal.jsx rename to client/components/BillModal.tsx index 0552e03..87b85a0 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { - Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -16,12 +16,12 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; -import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; +import { cn, todayStr, errMessage } from '@/lib/utils'; import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection'; import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; import PaymentFormFields from '@/components/bill-modal/PaymentFormFields'; -import UnmatchDialogs from '@/components/bill-modal/UnmatchDialogs'; +import UnmatchDialogs, { type BulkUnmatchState } from '@/components/bill-modal/UnmatchDialogs'; import LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection'; import TemplateSection from '@/components/bill-modal/TemplateSection'; import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay'; @@ -31,8 +31,11 @@ import { defaultCycleDayForSchedule, scheduleValue, } from '@/lib/billingSchedule'; +import type { AutopayStats, BankTransaction, Bill, Category, Payment } from '@/types'; -function getOrdinalSuffix(day) { +type FormErrors = Record; + +function getOrdinalSuffix(day: number): string { if (day > 3 && day < 21) return 'th'; switch (day % 10) { case 1: return 'st'; @@ -46,7 +49,7 @@ function getOrdinalSuffix(day) { const CAT_NONE = 'none'; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing']; -const SUBSCRIPTION_TYPES = [ +const SUBSCRIPTION_TYPES: [string, string][] = [ ['streaming', 'Streaming'], ['software', 'Software'], ['cloud', 'Cloud'], @@ -59,18 +62,27 @@ const SUBSCRIPTION_TYPES = [ ['other', 'Other'], ]; -function isDebtCat(categories, catId) { +function isDebtCat(categories: Category[], catId: string | null | undefined): boolean { if (!catId || catId === CAT_NONE) return false; const cat = categories.find(c => String(c.id) === catId); - return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; + return cat ? DEBT_KEYWORDS.some(kw => String(cat.name).toLowerCase().includes(kw)) : false; } -function isSnowballCat(categories, catId) { +function isSnowballCat(categories: Category[], catId: string | null | undefined): boolean { if (!catId || catId === CAT_NONE) return false; const cat = categories.find(c => String(c.id) === catId); - return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; + return cat ? SNOWBALL_KEYWORDS.some(kw => String(cat.name).toLowerCase().includes(kw)) : false; } -export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) { +interface BillModalProps { + bill?: Bill | null; + initialBill?: Partial | null; // a pre-filled draft (from a transaction/subscription/template); lacks id + full Bill fields + categories: Category[]; + onClose: () => void; + onSave: (savedBill?: Bill) => void; + onDuplicate?: (bill: Bill) => void; +} + +export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }: BillModalProps) { const isNew = !bill; const sourceBill = bill || initialBill || null; @@ -81,7 +93,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); const initialCycleType = scheduleValue(sourceBill || {}); const [cycleType, setCycleType] = useState(initialCycleType); - const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType)); + const [cycleDay, setCycleDay] = useState(String(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType))); 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); @@ -110,22 +122,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa ); const [saveTemplate, setSaveTemplate] = useState(false); const [templateName, setTemplateName] = useState(''); - const [errors, setErrors] = useState({}); - const [payments, setPayments] = useState([]); + const [errors, setErrors] = useState({}); + const [payments, setPayments] = useState([]); const [paymentsLoading, setPaymentsLoading] = useState(false); - const [linkedTransactions, setLinkedTransactions] = useState([]); + const [linkedTransactions, setLinkedTransactions] = useState([]); const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false); - const [transactionBusyId, setTransactionBusyId] = useState(null); + const [transactionBusyId, setTransactionBusyId] = useState(null); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentFormOpen, setPaymentFormOpen] = useState(false); - const [editingPayment, setEditingPayment] = useState(null); - const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); + const [editingPayment, setEditingPayment] = useState(null); + const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); const [paymentAmount, setPaymentAmount] = useState(''); const [paymentDate, setPaymentDate] = useState(todayStr()); const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentNotes, setPaymentNotes] = useState(''); - const [localVerifiedAt, setLocalVerifiedAt] = useState( - bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null + const [localVerifiedAt, setLocalVerifiedAt] = useState( + bill?.autopay_verified_at ? new Date(bill.autopay_verified_at as string) : null ); // Controls the outer Dialog's open state so it closes via its own animation @@ -137,9 +149,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [deactivateReason, setDeactivateReason] = useState(''); // Unmatch dialog state - const [unmatchTarget, setUnmatchTarget] = useState(null); + const [unmatchTarget, setUnmatchTarget] = useState(null); const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); - const [bulkUnmatch, setBulkUnmatch] = useState(null); + const [bulkUnmatch, setBulkUnmatch] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); const isSnowballCategory = isSnowballCat(categories, categoryId); @@ -150,10 +162,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (isNew || !bill?.id) return; setPaymentsLoading(true); try { - const data = await api.billPayments(bill.id, 1, 100); + const data = await api.billPayments(bill.id, 1, 100) as { payments?: Payment[] }; setPayments(data.payments || []); } catch (err) { - toast.error(err.message || 'Failed to load payment history.'); + toast.error(errMessage(err, 'Failed to load payment history.')); } finally { setPaymentsLoading(false); } @@ -163,10 +175,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (isNew || !bill?.id) return; setLinkedTransactionsLoading(true); try { - const data = await api.billTransactions(bill.id); + const data = await api.billTransactions(bill.id) as { transactions?: BankTransaction[] }; setLinkedTransactions(data.transactions || []); } catch (err) { - toast.error(err.message || 'Failed to load linked transactions.'); + toast.error(errMessage(err, 'Failed to load linked transactions.')); } finally { setLinkedTransactionsLoading(false); } @@ -189,18 +201,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } async function handleSyncBillPayments() { + if (!sourceBill?.id) return; setSyncingPayments(true); const promise = api.syncBillSimplefinPayments(sourceBill.id); toast.promise(promise, { loading: 'Scanning bank history…', - success: (result) => result.added > 0 - ? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.` - : 'No new matching transactions found.', - error: (err) => err.message || 'Sync failed.', + success: (r) => { + const added = (r as { added?: number }).added ?? 0; + return added > 0 + ? `${added} payment${added !== 1 ? 's' : ''} imported from bank history.` + : 'No new matching transactions found.'; + }, + error: (err) => errMessage(err, 'Sync failed.'), }); try { - const result = await promise; - if (result.added > 0) await refreshAfterImport(); + const result = await promise as { added?: number; late_attributions?: unknown[] }; + if ((result.added ?? 0) > 0) await refreshAfterImport(); if (result.late_attributions?.length) { window.dispatchEvent(new CustomEvent('tracker:late-attributions', { detail: { attributions: result.late_attributions }, @@ -218,13 +234,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await refreshAfterImport(); } - const validateName = (val) => { + const validateName = (val: string): string => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; return ''; }; - const validateDueDay = (val) => { + const validateDueDay = (val: string): string => { if (!val || val.trim() === '') return 'Due day is required'; const num = parseInt(val, 10); if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31'; @@ -232,11 +248,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa }; // Money fields share one non-negative validator (blank allowed, 0 allowed). - const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount'); - const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance'); - const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment'); + const validateExpectedAmount = (val: string): string => validateNonNegativeMoney(val, 'Amount'); + const validateCurrentBalance = (val: string): string => validateNonNegativeMoney(val, 'Balance'); + const validateMinimumPayment = (val: string): string => validateNonNegativeMoney(val, 'Min payment'); - const validateInterestRate = (val) => { + const validateInterestRate = (val: string): string => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num)) return 'Invalid number'; @@ -244,8 +260,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa return ''; }; - const validateForm = () => { - const newErrors = { + const validateForm = (): boolean => { + const newErrors: FormErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), @@ -260,11 +276,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa // Value passed explicitly so this never falls through to the wrong field's // state (the old positional guessing defaulted every unmapped field to // interestRate). - const handleBlur = (field, value, validator) => { + const handleBlur = (field: string, value: string, validator: (v: string) => string) => { setErrors(prev => ({ ...prev, [field]: validator(value) })); }; - const handleCategoryChange = (val) => { + const handleCategoryChange = (val: string) => { setCategoryId(val); if (isDebtCat(categories, val)) { setShowDebtSection(true); @@ -273,7 +289,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } }; - const handleSnowballVisibilityChange = (checked) => { + const handleSnowballVisibilityChange = (checked: boolean) => { if (checked) { setSnowballExempt(false); setSnowballInclude(!isSnowballCategory); @@ -286,15 +302,15 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa async function handleVerifyAutopay() { if (!bill?.id) return; try { - const res = await api.verifyAutopay(bill.id); - setLocalVerifiedAt(new Date(res.autopay_verified_at)); + const res = await api.verifyAutopay(bill.id) as { autopay_verified_at?: string }; + setLocalVerifiedAt(new Date(res.autopay_verified_at ?? Date.now())); toast.success('Autopay marked as verified.'); } catch (err) { - toast.error(err.message || 'Failed to verify autopay.'); + toast.error(errMessage(err, 'Failed to verify autopay.')); } } - const handleAutopayChange = (checked) => { + const handleAutopayChange = (checked: boolean) => { setAutopay(checked); if (checked) { setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid')); @@ -304,7 +320,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } }; - const handleCycleTypeChange = (value) => { + const handleCycleTypeChange = (value: string) => { setCycleType(value); setCycleDay(defaultCycleDayForSchedule(value)); }; @@ -322,7 +338,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa setPaymentFormOpen(true); } - function startEditPayment(payment) { + function startEditPayment(payment: Payment) { setEditingPayment(payment); setPaymentAmount(String(payment.amount ?? '')); setPaymentDate(payment.paid_date || todayStr()); @@ -331,7 +347,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa setPaymentFormOpen(true); } - async function handlePaymentSubmit(e) { + async function handlePaymentSubmit(e: React.FormEvent) { e.preventDefault(); const parsedAmount = parseFloat(paymentAmount); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { @@ -356,7 +372,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await api.updatePayment(editingPayment.id, payload); toast.success('Payment updated'); } else { - await api.createPayment({ ...payload, bill_id: bill.id }); + await api.createPayment({ ...payload, bill_id: bill?.id }); toast.success('Payment added'); } resetPaymentForm(); @@ -364,7 +380,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await loadPayments(); onSave?.(); } catch (err) { - toast.error(err.message || 'Payment could not be saved.'); + toast.error(errMessage(err, 'Payment could not be saved.')); } finally { setPaymentBusy(false); } @@ -387,7 +403,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await loadPayments(); onSave?.(); } catch (err) { - toast.error(err.message || 'Failed to restore payment.'); + toast.error(errMessage(err, 'Failed to restore payment.')); } }, }, @@ -399,13 +415,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await loadPayments(); onSave?.(); } catch (err) { - toast.error(err.message || 'Payment could not be removed.'); + toast.error(errMessage(err, 'Payment could not be removed.')); } finally { setPaymentBusy(false); } } - function openUnmatch(transaction) { + function openUnmatch(transaction: BankTransaction) { setUnmatchTarget(transaction); setBulkUnmatch(null); setUnmatchConfirmOpen(false); @@ -427,7 +443,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { - toast.error(err.message || 'Transaction could not be unmatched.'); + toast.error(errMessage(err, 'Transaction could not be unmatched.')); } finally { setTransactionBusyId(null); } @@ -442,9 +458,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (!similar.find(tx => tx.id === unmatchTarget.id)) { similar.unshift(unmatchTarget); } - let rules = []; + let rules: { id: number; merchant: string }[] = []; try { - const ruleData = await api.billMerchantRules(bill.id); + const ruleData = await api.billMerchantRules(bill.id) as { id: number; merchant: string }[] | null; rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee)); } catch { // ignore β€” rules are optional @@ -462,16 +478,19 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const { similar, checkedIds, removeRuleId } = bulkUnmatch; const matches = similar .filter(tx => checkedIds.has(tx.id) && tx.linked_payment) - .map(tx => ({ - transaction_id: tx.id, - payment_id: tx.linked_payment.id, - payment_source: tx.linked_payment.payment_source, - })); + .map(tx => { + const lp = tx.linked_payment as { id: number; payment_source?: string }; + return { + transaction_id: tx.id, + payment_id: lp.id, + payment_source: lp.payment_source, + }; + }); if (matches.length === 0) { closeUnmatch(); return; } setBulkBusy(true); try { await api.unmatchTransactionBulk(matches); - if (removeRuleId) { + if (removeRuleId && bill?.id) { try { await api.deleteMerchantRule(bill.id, removeRuleId); } catch { @@ -483,7 +502,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { - toast.error(err.message || 'Could not unmatch transactions.'); + toast.error(errMessage(err, 'Could not unmatch transactions.')); } finally { setBulkBusy(false); } @@ -533,10 +552,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa snowball_exempt: snowballExempt, }; try { - let savedBill; + let savedBill: Bill; if (isNew) { if (data.source_bill_id) { - savedBill = await api.duplicateBill(data.source_bill_id, data); + savedBill = await api.duplicateBill(data.source_bill_id as number, data) as Bill; } else { savedBill = await api.createBill(data); } @@ -553,21 +572,21 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa onSave(savedBill); setDialogOpen(false); } catch (err) { - toast.error(err.message || 'Failed to save bill.'); + toast.error(errMessage(err, 'Failed to save bill.')); } }, null); async function handleDeactivate() { if (!bill?.id) return; try { - const payload = { active: bill.active ? 0 : 1 }; + const payload: { active: number; inactive_reason?: string } = { active: bill.active ? 0 : 1 }; if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason; await api.updateBill(bill.id, payload); toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); onSave?.(); setDialogOpen(false); } catch (err) { - toast.error(err.message || 'Failed to update bill.'); + toast.error(errMessage(err, 'Failed to update bill.')); } } @@ -898,7 +917,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa diff --git a/client/components/BillRulesManager.jsx b/client/components/BillRulesManager.tsx similarity index 81% rename from client/components/BillRulesManager.jsx rename to client/components/BillRulesManager.tsx index 1cc99dd..62e325a 100644 --- a/client/components/BillRulesManager.jsx +++ b/client/components/BillRulesManager.tsx @@ -1,22 +1,36 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; + +interface MerchantRule { + id: number; + bill_id: number; + bill_name?: string; + merchant: string; + auto_attribute_late?: number; +} + +interface RuleGroup { + bill_name?: string; + bill_id: number; + rules: MerchantRule[]; +} export default function BillRulesManager() { const [open, setOpen] = useState(false); - const [rules, setRules] = useState([]); + const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const load = useCallback(async () => { setLoading(true); try { - const d = await api.allBillMerchantRules(); + const d = await api.allBillMerchantRules() as { rules?: MerchantRule[] }; setRules(d.rules || []); } catch (err) { - toast.error(err.message || 'Failed to load bill matching rules'); + toast.error(errMessage(err, 'Failed to load bill matching rules')); } finally { setLoading(false); } @@ -24,32 +38,32 @@ export default function BillRulesManager() { useEffect(() => { if (open) load(); }, [open, load]); - const handleDelete = async (billId, ruleId, merchant) => { + const handleDelete = async (billId: number, ruleId: number, merchant: string) => { try { await api.deleteMerchantRule(billId, ruleId); setRules(prev => prev.filter(r => r.id !== ruleId)); toast.success(`Rule "${merchant}" removed`); } catch (err) { - toast.error(err.message || 'Failed to delete rule'); + toast.error(errMessage(err, 'Failed to delete rule')); } }; - const handleToggleAutoLate = async (billId, ruleId, current) => { + const handleToggleAutoLate = async (billId: number, ruleId: number, current: boolean) => { try { await api.toggleRuleAutoAttribute(billId, ruleId, !current); setRules(prev => prev.map(r => r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r )); } catch (err) { - toast.error(err.message || 'Failed to update rule'); + toast.error(errMessage(err, 'Failed to update rule')); } }; // Group rules by bill - const byBill = rules.reduce((acc, r) => { + const byBill = rules.reduce>((acc, r) => { const key = r.bill_id; - if (!acc[key]) acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] }; - acc[key].rules.push(r); + const g = acc[key] ?? (acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] }); + g.rules.push(r); return acc; }, {}); const groups = Object.values(byBill); diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.tsx similarity index 84% rename from client/components/BillsTableInner.jsx rename to client/components/BillsTableInner.tsx index dd19edc..85f75a5 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.tsx @@ -1,11 +1,27 @@ +import React from 'react'; import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { scheduleLabel } from '@/lib/billingSchedule'; import { formatUSDWhole } from '@/lib/money'; import { MobileBillRow } from '@/components/MobileBillRow'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { Bill } from '@/types'; +import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; -function ordinal(n) { +interface BillPrefs { + showCategory?: boolean; + showDueDay?: boolean; + showAmount?: boolean; + showCycle?: boolean; + showApr?: boolean; + showBalance?: boolean; + showMinPayment?: boolean; + showAutopay?: boolean; + show2fa?: boolean; + showSubscription?: boolean; +} + +function ordinal(n: number | null | undefined): string { const d = Number(n); if (!d) return 'β€”'; if (d > 3 && d < 21) return `${d}th`; @@ -17,11 +33,11 @@ function ordinal(n) { } } -function hasHistoricalVisibility(bill) { - return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default'); +function hasHistoricalVisibility(bill: Bill): boolean { + return !!bill.has_history_ranges || (!!bill.history_visibility && bill.history_visibility !== 'default'); } -function AprColor({ rate }) { +function AprColor({ rate }: { rate: number }) { const cls = rate >= 25 ? 'text-rose-400' : rate >= 15 ? 'text-amber-400' : @@ -29,23 +45,35 @@ function AprColor({ rate }) { return {rate}% APR; } -const ALL_ON = { +const ALL_ON: BillPrefs = { showCategory: true, showDueDay: true, showAmount: true, showCycle: true, showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, }; -function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { +interface BillCardProps { + bill: Bill; + prefs?: BillPrefs; + onEdit?: (id: number) => void; + onToggle?: (bill: Bill) => void; + onDelete?: (bill: Bill) => void; + onHistory?: (bill: Bill) => void; + onDuplicate?: (bill: Bill) => void; + moveControls?: MoveControls; + dragProps?: DragProps; +} + +function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: BillCardProps) { const isDebt = bill.current_balance != null || bill.minimum_payment != null; const hasHistory = hasHistoricalVisibility(bill); return (
['onDragStart']} + onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']} + onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']} + onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']} + onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']} className={cn( 'group flex items-center gap-3 px-5 py-3.5 transition-colors', 'hover:bg-accent/20', @@ -260,7 +288,19 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, ); } -export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) { +interface BillsTableInnerProps { + bills: Bill[]; + prefs?: BillPrefs; + onEdit?: (id: number) => void; + onToggle?: (bill: Bill) => void; + onDelete?: (bill: Bill) => void; + onHistory?: (bill: Bill) => void; + onDuplicate?: (bill: Bill) => void; + moveControlsFor?: (bill: Bill, index: number) => MoveControls | undefined; + dragPropsFor?: (bill: Bill, index: number) => DragProps | undefined; +} + +export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }: BillsTableInnerProps) { return ( <>
diff --git a/client/components/CalendarFeedManager.jsx b/client/components/CalendarFeedManager.tsx similarity index 82% rename from client/components/CalendarFeedManager.jsx rename to client/components/CalendarFeedManager.tsx index 36cc361..0c564ec 100644 --- a/client/components/CalendarFeedManager.jsx +++ b/client/components/CalendarFeedManager.tsx @@ -1,13 +1,27 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -function PlatformNote({ title, children }) { +interface CalFeed { + active?: boolean; + feed_url?: string; + last_used_at?: string | null; +} + +interface CalEvent { + uid: string; + name?: string; + due_date?: string; + cycle_type?: string; + amount?: number; +} + +function PlatformNote({ title, children }: { title: ReactNode; children: ReactNode }) { return (

{title}

@@ -16,25 +30,25 @@ function PlatformNote({ title, children }) { ); } -export function CalendarFeedManager({ compact = false, showManageLink = false }) { - const [feed, setFeed] = useState(null); - const [preview, setPreview] = useState([]); +export function CalendarFeedManager({ compact = false, showManageLink = false }: { compact?: boolean; showManageLink?: boolean }) { + const [feed, setFeed] = useState(null); + const [preview, setPreview] = useState([]); const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(null); + const [busy, setBusy] = useState(null); const loadFeed = useCallback(async () => { setLoading(true); try { - const data = await api.calendarFeed(); + const data = await api.calendarFeed() as CalFeed; setFeed(data); if (data?.active) { - const nextPreview = await api.calendarFeedPreview(10); + const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] }; setPreview(nextPreview.events || []); } else { setPreview([]); } } catch (err) { - toast.error(err.message || 'Failed to load calendar feed.'); + toast.error(errMessage(err, 'Failed to load calendar feed.')); } finally { setLoading(false); } @@ -47,13 +61,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false }) async function createFeed() { setBusy('create'); try { - const data = await api.createCalendarFeed(); + const data = await api.createCalendarFeed() as CalFeed; setFeed(data); - const nextPreview = await api.calendarFeedPreview(10); + const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] }; setPreview(nextPreview.events || []); toast.success('Calendar feed created.'); } catch (err) { - toast.error(err.message || 'Failed to create calendar feed.'); + toast.error(errMessage(err, 'Failed to create calendar feed.')); } finally { setBusy(null); } @@ -72,13 +86,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false }) async function regenerateFeed() { setBusy('regenerate'); try { - const data = await api.regenerateCalendarFeed(); + const data = await api.regenerateCalendarFeed() as CalFeed; setFeed(data); - const nextPreview = await api.calendarFeedPreview(10); + const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] }; setPreview(nextPreview.events || []); toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.'); } catch (err) { - toast.error(err.message || 'Failed to regenerate calendar feed.'); + toast.error(errMessage(err, 'Failed to regenerate calendar feed.')); } finally { setBusy(null); } @@ -87,12 +101,12 @@ export function CalendarFeedManager({ compact = false, showManageLink = false }) async function revokeFeed() { setBusy('revoke'); try { - const data = await api.revokeCalendarFeed(); + const data = await api.revokeCalendarFeed() as CalFeed; setFeed(data); setPreview([]); toast.success('Calendar feed revoked.'); } catch (err) { - toast.error(err.message || 'Failed to revoke calendar feed.'); + toast.error(errMessage(err, 'Failed to revoke calendar feed.')); } finally { setBusy(null); } @@ -150,13 +164,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
- + @@ -140,7 +153,7 @@ function BillResult({ bill, onOpenBills, onOpenTracker }) { ); } -function CommandResult({ cmd, onRun }) { +function CommandResult({ cmd, onRun }: { cmd: Command; onRun: (cmd: Command) => void }) { const Icon = cmd.icon || Navigation; return (
- {bt.pending_payments > 0 && ( + {(bt.pending_payments ?? 0) > 0 && (
Pending payments ({bt.pending_days}d window) βˆ’{fmt(bt.pending_payments)} diff --git a/client/components/MarkdownText.jsx b/client/components/MarkdownText.tsx similarity index 66% rename from client/components/MarkdownText.jsx rename to client/components/MarkdownText.tsx index 1f7f4a6..5ac5b7f 100644 --- a/client/components/MarkdownText.jsx +++ b/client/components/MarkdownText.tsx @@ -1,8 +1,8 @@ -import { Fragment } from 'react'; +import { Fragment, isValidElement, type ReactNode } from 'react'; const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g; -function renderToken(token, key) { +function renderToken(token: string, key: string): ReactNode { if (token.startsWith('**') && token.endsWith('**')) { return ( @@ -37,18 +37,19 @@ function renderToken(token, key) { return token; } -export function renderInlineMarkdown(text) { +export function renderInlineMarkdown(text: string | null | undefined): ReactNode { if (!text) return null; - const parts = []; + const parts: ReactNode[] = []; let lastIndex = 0; for (const match of text.matchAll(TOKEN_RE)) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); + const mi = match.index ?? 0; + if (mi > lastIndex) { + parts.push(text.slice(lastIndex, mi)); } - parts.push(renderToken(match[0], `md-${match.index}`)); - lastIndex = match.index + match[0].length; + parts.push(renderToken(match[0], `md-${mi}`)); + lastIndex = mi + match[0].length; } if (lastIndex < text.length) { @@ -56,12 +57,12 @@ export function renderInlineMarkdown(text) { } return parts.map((part, index) => ( - + {part} )); } -export function MarkdownText({ text }) { +export function MarkdownText({ text }: { text?: string | null }): ReactNode { return renderInlineMarkdown(text); } diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.tsx similarity index 85% rename from client/components/MobileBillRow.jsx rename to client/components/MobileBillRow.tsx index 0cb863c..0e8da78 100644 --- a/client/components/MobileBillRow.jsx +++ b/client/components/MobileBillRow.tsx @@ -3,15 +3,28 @@ import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { scheduleLabel } from '@/lib/billingSchedule'; +import type { Bill } from '@/types'; +import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; -function hasHistoricalVisibility(bill) { +function hasHistoricalVisibility(bill: Bill): boolean { const visibility = bill.history_visibility; - return !!bill.has_history_ranges || (visibility && visibility !== 'default'); + return !!bill.has_history_ranges || (!!visibility && visibility !== 'default'); } -export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { +interface MobileBillRowProps { + bill: Bill; + onEdit?: (id: number) => void; + onToggle?: (bill: Bill) => void; + onDelete?: (bill: Bill) => void; + onHistory?: (bill: Bill) => void; + onDuplicate?: (bill: Bill) => void; + moveControls?: MoveControls; + dragProps?: DragProps; +} + +export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: MobileBillRowProps) { const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]); - + const statusClass = useMemo(() => { return cn( 'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide', @@ -33,11 +46,11 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o return (
['onDragStart']} + onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']} + onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']} + onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']} + onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']} className={cn( 'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10', dragProps?.isDragging && 'opacity-45', @@ -103,18 +116,18 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o {bill.active ? 'Active' : 'Inactive'} - {bill.autopay_enabled && ( + {bill.autopay_enabled ? ( AP - )} - {bill.has_2fa && ( + ) : null} + {bill.has_2fa ? ( 2FA - )} - {(bill.has_merchant_rule || bill.has_linked_transactions) && ( + ) : null} + {(bill.has_merchant_rule || bill.has_linked_transactions) ? ( L - )} - {bill.is_subscription && ( + ) : null} + {bill.is_subscription ? ( S - )} + ) : null}
diff --git a/client/components/PageLoader.jsx b/client/components/PageLoader.tsx similarity index 99% rename from client/components/PageLoader.jsx rename to client/components/PageLoader.tsx index f778537..4d18558 100644 --- a/client/components/PageLoader.jsx +++ b/client/components/PageLoader.tsx @@ -6,4 +6,4 @@ export default function PageLoader() {
); -} \ No newline at end of file +} diff --git a/client/components/PageTransition.jsx b/client/components/PageTransition.tsx similarity index 77% rename from client/components/PageTransition.jsx rename to client/components/PageTransition.tsx index 1b61ff4..d07ea0f 100644 --- a/client/components/PageTransition.jsx +++ b/client/components/PageTransition.tsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import type { ReactNode } from 'react'; -export default function PageTransition({ children, routeKey }) { +export default function PageTransition({ children, routeKey }: { children: ReactNode; routeKey?: string }) { const reduceMotion = useReducedMotion(); if (reduceMotion) return children; diff --git a/client/components/RecentlyDeletedBillsDialog.jsx b/client/components/RecentlyDeletedBillsDialog.tsx similarity index 84% rename from client/components/RecentlyDeletedBillsDialog.jsx rename to client/components/RecentlyDeletedBillsDialog.tsx index 991342d..b31436e 100644 --- a/client/components/RecentlyDeletedBillsDialog.jsx +++ b/client/components/RecentlyDeletedBillsDialog.tsx @@ -5,14 +5,22 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { formatUSD } from '@/lib/money'; +import type { Bill } from '@/types'; -function daysLeftLabel(days) { +function daysLeftLabel(days: number | null | undefined): string | null { if (days == null) return null; if (days <= 0) return 'purges today'; if (days === 1) return '1 day left'; return `${days} days left`; } +interface RecentlyDeletedBillsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bills?: Bill[]; + onRestore: (bill: Bill) => void | Promise; +} + /** * Lists bills that were soft-deleted within the 30-day recovery window and lets * the user restore them β€” a durable path beyond the transient "Undo" toast. @@ -20,10 +28,10 @@ function daysLeftLabel(days) { * Presentational: the parent owns the list (`bills`) and the async `onRestore`, * so restoring refreshes the page's active bills too. */ -export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) { - const [busyId, setBusyId] = useState(null); +export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }: RecentlyDeletedBillsDialogProps) { + const [busyId, setBusyId] = useState(null); - async function handleRestore(bill) { + async function handleRestore(bill: Bill) { setBusyId(bill.id); try { await onRestore(bill); @@ -51,7 +59,7 @@ export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = ) : (
    {bills.map(bill => { - const left = daysLeftLabel(bill.days_left); + const left = daysLeftLabel(bill.days_left as number | null | undefined); const busy = busyId === bill.id; return (
  • diff --git a/client/components/ReleaseNotesDialog.jsx b/client/components/ReleaseNotesDialog.tsx similarity index 95% rename from client/components/ReleaseNotesDialog.jsx rename to client/components/ReleaseNotesDialog.tsx index 67a5123..25c3ae7 100644 --- a/client/components/ReleaseNotesDialog.jsx +++ b/client/components/ReleaseNotesDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { APP_VERSION, RELEASE_NOTES } from '@/lib/version'; +import { RELEASE_NOTES } from '@/lib/version'; import { Sparkles } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { api } from '@/api'; @@ -9,7 +9,7 @@ import { api } from '@/api'; export function ReleaseNotesDialog() { const { hasNewVersion, setHasNewVersion } = useAuth(); const [open, setOpen] = useState(false); - const titleRef = useRef(null); + const titleRef = useRef(null); useEffect(() => { if (hasNewVersion) setOpen(true); @@ -19,7 +19,7 @@ export function ReleaseNotesDialog() { setOpen(false); setHasNewVersion(false); // optimistic β€” don't wait for the server api.acknowledgeVersion().catch(err => console.error('[ReleaseNotesDialog] failed to acknowledge version', err)); // backend stores the seen release version - const prev = document.activeElement; + const prev = document.activeElement as HTMLElement | null; if (prev?.focus) setTimeout(() => prev.focus(), 0); }; diff --git a/client/components/SearchFilterPanel.jsx b/client/components/SearchFilterPanel.tsx similarity index 84% rename from client/components/SearchFilterPanel.jsx rename to client/components/SearchFilterPanel.tsx index f405ed4..52e4128 100644 --- a/client/components/SearchFilterPanel.jsx +++ b/client/components/SearchFilterPanel.tsx @@ -1,7 +1,22 @@ +import type { ReactNode } from 'react'; import { ChevronDown, ChevronUp, Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +interface SearchFilterPanelProps { + title?: string; + collapsed?: boolean; + onCollapsedChange?: (collapsed: boolean) => void; + hasFilters?: boolean; + resultLabel?: string; + sortLabel?: string; + onClear?: () => void; + children?: ReactNode; + className?: string; + variant?: 'default' | 'embedded'; + headerActions?: ReactNode; +} + export default function SearchFilterPanel({ title = 'Search & filters', collapsed, @@ -14,7 +29,7 @@ export default function SearchFilterPanel({ className, variant = 'default', headerActions, -}) { +}: SearchFilterPanelProps) { const embedded = variant === 'embedded'; const ToggleIcon = collapsed ? ChevronUp : ChevronDown; diff --git a/client/components/StatusBadge.jsx b/client/components/StatusBadge.tsx similarity index 92% rename from client/components/StatusBadge.jsx rename to client/components/StatusBadge.tsx index 6560c14..16db054 100644 --- a/client/components/StatusBadge.jsx +++ b/client/components/StatusBadge.tsx @@ -12,8 +12,8 @@ const STATUS_META = { skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; -export const StatusBadge = React.memo(function StatusBadge({ status }) { - const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); +export const StatusBadge = React.memo(function StatusBadge({ status }: { status: string }) { + const meta = useMemo(() => STATUS_META[status as keyof typeof STATUS_META] || STATUS_META.upcoming, [status]); const isUrgent = status === 'late' || status === 'missed'; return ( = { streaming: 'Streaming', software: 'Software', cloud: 'Cloud', music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming', utilities: 'Utilities', insurance: 'Insurance', food: 'Food', education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other', }; -const CHIPS = [ +const CHIPS: { value: string | null; label: string }[] = [ { value: null, label: 'All' }, { value: 'streaming', label: 'Streaming' }, { value: 'music', label: 'Music' }, @@ -40,7 +62,7 @@ const CHIPS = [ ]; // Returns 'match' | 'above' | 'below' | null -function priceDrift(monthly, catalogStarting) { +function priceDrift(monthly: number | null | undefined, catalogStarting: number | null | undefined): 'match' | 'above' | 'below' | null { if (!catalogStarting || !monthly) return null; const pct = (monthly - catalogStarting) / catalogStarting; if (Math.abs(pct) <= 0.05) return 'match'; @@ -48,7 +70,12 @@ function priceDrift(monthly, catalogStarting) { } // ── Descriptor editor inline inside a matched card ───────────────────────── -function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { +function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }: { + catalogId: number | string; + descriptors: Descriptor[]; + onAdd: (catalogId: number | string, descriptor: string) => Promise | void; + onDelete: (descriptorId: number | string) => void; +}) { const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); @@ -126,7 +153,13 @@ function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { } // ── Card for an already-tracked catalog entry ────────────────────────────── -function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) { +function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }: { + entry: CatalogEntry; + onEdit: (id: number) => void; + onRelink: (entry: CatalogEntry) => void; + onAddDescriptor: (catalogId: number | string, descriptor: string) => Promise | void; + onDeleteDescriptor: (descriptorId: number | string) => void; +}) { const { matched_bill: bill, user_descriptors: descs = [] } = entry; const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd); @@ -140,7 +173,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip variant="outline" className="border-primary/25 bg-primary/10 text-[10px] text-primary" > - {TYPE_LABELS[entry.subscription_type] || 'Other'} + {TYPE_LABELS[entry.subscription_type ?? ''] || 'Other'} {bill && !bill.active && ( @@ -161,7 +194,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip )} {drift === 'above' && ( - catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo + catalog from ${entry.starting_monthly_usd?.toFixed(2)}/mo )} {drift === 'below' && entry.starting_monthly_usd && ( @@ -188,7 +221,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip size="sm" variant="outline" className="h-7 gap-1 text-xs" - onClick={() => onEdit(bill.id)} + onClick={() => bill && onEdit(bill.id)} > Edit @@ -218,7 +251,13 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip } // ── Card for an untracked catalog entry ─────────────────────────────────── -function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) { +function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }: { + entry: CatalogEntry; + selected: boolean; + onToggleSelect: (id: number | string, checked: boolean) => void; + onTrack: (entry: CatalogEntry) => void; + trackingBusy: boolean; +}) { return (
    void; + onConfirm: (billId: number | undefined, catalogId: number | string | null) => void; + busy: boolean; +}) { const [search, setSearch] = useState(''); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelected(null); } @@ -374,27 +420,30 @@ function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy } // ── Main component ───────────────────────────────────────────────────────── -export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) { - const [catalog, setCatalog] = useState([]); +export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }: { + onEditBill: (id: number) => void; + onTrackComplete?: () => void; +}) { + const [catalog, setCatalog] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [showUnmatched, setShowUnmatched] = useState(false); - const [activeType, setActiveType] = useState(null); + const [activeType, setActiveType] = useState(null); const [search, setSearch] = useState(''); - const [selectedIds, setSelectedIds] = useState(new Set()); - const [relinkEntry, setRelinkEntry] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [relinkEntry, setRelinkEntry] = useState(null); const [relinkBusy, setRelinkBusy] = useState(false); - const [trackBusyId, setTrackBusyId] = useState(null); + const [trackBusyId, setTrackBusyId] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); async function loadCatalog() { setLoading(true); setError(null); try { - const data = await api.subscriptionCatalog(); + const data = await api.subscriptionCatalog() as { catalog?: CatalogEntry[] }; setCatalog(data.catalog || []); } catch (err) { - const message = err.message || 'Failed to load catalog'; + const message = errMessage(err, 'Failed to load catalog'); setError(message); toast.error(message); } finally { @@ -414,12 +463,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }); }, [catalog, activeType, search]); - const matched = filtered.filter(e => e.matched_bill !== null); - const unmatched = filtered.filter(e => e.matched_bill === null); + const matched = filtered.filter(e => e.matched_bill != null); + const unmatched = filtered.filter(e => e.matched_bill == null); // ── Handlers ───────────────────────────────────────────────────────────── - async function handleTrack(entry) { + async function handleTrack(entry: CatalogEntry) { setTrackBusyId(entry.id); try { await api.createSubscriptionFromRecommendation({ @@ -436,7 +485,7 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete await loadCatalog(); onTrackComplete?.(); } catch (err) { - toast.error(err.message || `Failed to add ${entry.name}`); + toast.error(errMessage(err, `Failed to add ${entry.name}`)); } finally { setTrackBusyId(null); } @@ -475,23 +524,24 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete setBulkBusy(false); } - async function handleRelink(billId, catalogId) { + async function handleRelink(billId: number | undefined, catalogId: number | string | null) { + if (billId == null) return; setRelinkBusy(true); try { - await api.updateSubscriptionCatalogLink(billId, catalogId); + await api.updateSubscriptionCatalogLink(billId, catalogId as number | string); toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed'); setRelinkEntry(null); await loadCatalog(); } catch (err) { - toast.error(err.message || 'Failed to update catalog link'); + toast.error(errMessage(err, 'Failed to update catalog link')); } finally { setRelinkBusy(false); } } - async function handleAddDescriptor(catalogId, descriptor) { + async function handleAddDescriptor(catalogId: number | string, descriptor: string): Promise { try { - const newDesc = await api.addCatalogDescriptor(catalogId, descriptor); + const newDesc = await api.addCatalogDescriptor(catalogId, descriptor) as Descriptor; setCatalog(prev => prev.map(e => e.id === catalogId ? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] } @@ -500,12 +550,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete toast.success('Descriptor added'); return true; } catch (err) { - toast.error(err.message || 'Failed to add descriptor'); + toast.error(errMessage(err, 'Failed to add descriptor')); return false; } } - async function handleDeleteDescriptor(catalogId, descriptorId) { + async function handleDeleteDescriptor(catalogId: number | string, descriptorId: number | string) { try { await api.deleteCatalogDescriptor(descriptorId); setCatalog(prev => prev.map(e => @@ -515,14 +565,15 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete )); toast.success('Descriptor removed'); } catch (err) { - toast.error(err.message || 'Failed to remove descriptor'); + toast.error(errMessage(err, 'Failed to remove descriptor')); } } - function toggleSelect(id, checked) { + function toggleSelect(id: number | string, checked: boolean) { setSelectedIds(prev => { const next = new Set(prev); - checked ? next.add(id) : next.delete(id); + if (checked) next.add(id); + else next.delete(id); return next; }); } diff --git a/client/components/SummaryCard.jsx b/client/components/SummaryCard.tsx similarity index 82% rename from client/components/SummaryCard.jsx rename to client/components/SummaryCard.tsx index 708b191..f8d07b3 100644 --- a/client/components/SummaryCard.jsx +++ b/client/components/SummaryCard.tsx @@ -1,10 +1,21 @@ import React, { useMemo } from 'react'; import { cn, fmt } from '@/lib/utils'; -import { AlertCircle, CheckCircle2, Clock, TrendingUp } from 'lucide-react'; -import { Settings2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { AlertCircle, CheckCircle2, Clock, TrendingUp, Settings2, type LucideIcon } from 'lucide-react'; +import type { Dollars } from '@/lib/money'; -const CARD_DEFS = { +type CardType = 'starting' | 'paid' | 'remaining' | 'overdue'; + +interface CardDef { + label: string; + icon: LucideIcon; + bar: string; + glow: string; + borderActive?: string; + valueClass: string; + activateWhen: (v: number) => boolean; +} + +const CARD_DEFS: Record = { starting: { label: 'Starting', icon: TrendingUp, @@ -41,7 +52,14 @@ const CARD_DEFS = { }, }; -export const SummaryCard = React.memo(function SummaryCard({ type, value, onEdit, hint }) { +interface SummaryCardProps { + type: CardType; + value?: Dollars; + onEdit?: () => void; + hint?: string; +} + +export const SummaryCard = React.memo(function SummaryCard({ type, value, onEdit, hint }: SummaryCardProps) { const def = useMemo(() => CARD_DEFS[type], [type]); const isActive = useMemo(() => def.activateWhen(value || 0), [def, value]); const Icon = useMemo(() => def.icon, [def]); diff --git a/client/components/admin/AddUserCard.jsx b/client/components/admin/AddUserCard.tsx similarity index 89% rename from client/components/admin/AddUserCard.jsx rename to client/components/admin/AddUserCard.tsx index ccb2c92..16ae09e 100644 --- a/client/components/admin/AddUserCard.jsx +++ b/client/components/admin/AddUserCard.tsx @@ -1,18 +1,19 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -export default function AddUserCard({ onCreated }) { +export default function AddUserCard({ onCreated }: { onCreated: () => void }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const handleCreate = async (e) => { + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -32,7 +33,7 @@ export default function AddUserCard({ onCreated }) { setError(''); onCreated(); } catch (err) { - const errorMessage = err.message || 'Failed to create user.'; + const errorMessage = errMessage(err, 'Failed to create user.'); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/client/components/admin/AuthMethodsCard.jsx b/client/components/admin/AuthMethodsCard.tsx similarity index 81% rename from client/components/admin/AuthMethodsCard.jsx rename to client/components/admin/AuthMethodsCard.tsx index aac86fc..a1edfa3 100644 --- a/client/components/admin/AuthMethodsCard.jsx +++ b/client/components/admin/AuthMethodsCard.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; @@ -8,45 +9,89 @@ import { FieldRow, Toggle } from './adminShared'; const AUTHENTIK_ICON_URL = '/img/auth.png'; -function defaultOidcRedirectUri() { +interface OidcForm { + local_login_enabled: boolean; + oidc_login_enabled: boolean; + oidc_provider_name: string; + oidc_issuer_url: string; + oidc_client_id: string; + oidc_client_secret: string; + oidc_client_secret_clear: boolean; + oidc_token_auth_method: string; + oidc_redirect_uri: string; + oidc_scopes: string; + oidc_auto_provision: boolean; + oidc_admin_group: string; + oidc_default_role: string; +} + +interface AuthConfigData { + local_login_enabled?: boolean; + oidc_login_enabled?: boolean; + oidc_provider_name?: string; + oidc_issuer_url?: string; + oidc_client_id?: string; + oidc_token_auth_method?: string; + oidc_redirect_uri?: string; + oidc_scopes?: string; + oidc_auto_provision?: boolean; + oidc_admin_group?: string; + oidc_default_role?: string; + oidc_client_secret_set?: boolean; + oidc_configured?: boolean; + oidc_env_fallback_used?: boolean; + warnings?: string[]; +} + +interface OidcTestResult { + ok?: boolean; + error?: string; + issuer?: string; +} + +function defaultOidcRedirectUri(): string { if (typeof window === 'undefined') return ''; return `${window.location.origin}/api/auth/oidc/callback`; } -function looksLikeOidcEndpoint(url) { +function looksLikeOidcEndpoint(url: string | null | undefined): boolean { const value = String(url || '').toLowerCase(); return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value); } +function formFromData(d: AuthConfigData): OidcForm { + return { + local_login_enabled: d.local_login_enabled !== false, + oidc_login_enabled: !!d.oidc_login_enabled, + oidc_provider_name: d.oidc_provider_name || 'authentik', + oidc_issuer_url: d.oidc_issuer_url || '', + oidc_client_id: d.oidc_client_id || '', + oidc_client_secret: '', + oidc_client_secret_clear: false, + oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', + oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), + oidc_scopes: d.oidc_scopes || 'openid email profile groups', + oidc_auto_provision: d.oidc_auto_provision !== false, + oidc_admin_group: d.oidc_admin_group || '', + oidc_default_role: d.oidc_default_role || 'user', + }; +} + export default function AuthMethodsCard() { - const [data, setData] = useState(null); - const [form, setForm] = useState(null); + const [data, setData] = useState(null); + const [form, setForm] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testingOidc, setTestingOidc] = useState(false); - const [oidcTest, setOidcTest] = useState(null); + const [oidcTest, setOidcTest] = useState(null); const load = useCallback(async () => { try { - const d = await api.authModeConfig(); + const d = await api.authModeConfig() as AuthConfigData; setData(d); - setForm({ - local_login_enabled: d.local_login_enabled !== false, - oidc_login_enabled: !!d.oidc_login_enabled, - oidc_provider_name: d.oidc_provider_name || 'authentik', - oidc_issuer_url: d.oidc_issuer_url || '', - oidc_client_id: d.oidc_client_id || '', - oidc_client_secret: '', - oidc_client_secret_clear: false, - oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', - oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), - oidc_scopes: d.oidc_scopes || 'openid email profile groups', - oidc_auto_provision: d.oidc_auto_provision !== false, - oidc_admin_group: d.oidc_admin_group || '', - oidc_default_role: d.oidc_default_role || 'user', - }); + setForm(formFromData(d)); } catch (err) { - toast.error(err.message || 'Failed to load auth settings.'); + toast.error(errMessage(err, 'Failed to load auth settings.')); } finally { setLoading(false); } @@ -54,31 +99,17 @@ export default function AuthMethodsCard() { useEffect(() => { load(); }, [load]); - const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); + const set = (k: keyof OidcForm, v: string | boolean) => setForm(prev => prev ? ({ ...prev, [k]: v }) as OidcForm : prev); async function handleSave() { setSaving(true); try { - const d = await api.setAuthMode(form); + const d = await api.setAuthMode(form) as AuthConfigData; setData(d); - setForm({ - local_login_enabled: d.local_login_enabled !== false, - oidc_login_enabled: !!d.oidc_login_enabled, - oidc_provider_name: d.oidc_provider_name || 'authentik', - oidc_issuer_url: d.oidc_issuer_url || '', - oidc_client_id: d.oidc_client_id || '', - oidc_client_secret: '', - oidc_client_secret_clear: false, - oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', - oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), - oidc_scopes: d.oidc_scopes || 'openid email profile groups', - oidc_auto_provision: d.oidc_auto_provision !== false, - oidc_admin_group: d.oidc_admin_group || '', - oidc_default_role: d.oidc_default_role || 'user', - }); + setForm(formFromData(d)); toast.success('Auth method settings saved.'); } catch (err) { - toast.error(err.message || 'Failed to save auth method settings.'); + toast.error(errMessage(err, 'Failed to save auth method settings.')); } finally { setSaving(false); } @@ -88,11 +119,12 @@ export default function AuthMethodsCard() { setTestingOidc(true); setOidcTest(null); try { - const result = await api.testOidcConfig(form); + const result = await api.testOidcConfig(form) as OidcTestResult; setOidcTest(result); toast.success('authentik configuration test passed.'); } catch (err) { - const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' }; + const e = err as { data?: OidcTestResult; message?: string }; + const result = e.data || { ok: false, error: e.message || 'OIDC configuration test failed.' }; setOidcTest(result); toast.error(result.error || 'OIDC configuration test failed.'); } finally { @@ -151,7 +183,7 @@ export default function AuthMethodsCard() { - {(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && ( + {((data?.warnings?.length ?? 0) > 0 || wouldLockOut || cantDisableLocal) && (
    {wouldLockOut && (

    @@ -229,11 +261,11 @@ export default function AuthMethodsCard() { setForm(prev => ({ + onChange={e => setForm(prev => prev ? ({ ...prev, oidc_client_secret: e.target.value, oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear, - }))} + }) : prev)} placeholder="Leave blank to keep existing secret" className="h-8 text-sm" /> diff --git a/client/components/admin/BackupManagementCard.jsx b/client/components/admin/BackupManagementCard.tsx similarity index 88% rename from client/components/admin/BackupManagementCard.jsx rename to client/components/admin/BackupManagementCard.tsx index 282c10b..bedbac0 100644 --- a/client/components/admin/BackupManagementCard.jsx +++ b/client/components/admin/BackupManagementCard.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmtBytes } from '@/lib/utils'; +import { cn, fmtBytes, errMessage } from '@/lib/utils'; import { Button, buttonVariants } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; @@ -18,7 +18,25 @@ import { import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -const DEFAULT_SETTINGS = { +interface Backup { + id: string; + type?: string; + modified_at?: string | null; + size_bytes?: number; + checksum?: string | null; +} + +interface BackupSettings { + enabled: boolean; + frequency: string; + time: string; + retention_count: number | string; + last_run_at: string | null; + next_run_at: string | null; + last_error: string | null; +} + +const DEFAULT_SETTINGS: BackupSettings = { enabled: false, frequency: 'daily', time: '02:00', @@ -29,24 +47,24 @@ const DEFAULT_SETTINGS = { }; export default function BackupManagementCard() { - const [backups, setBackups] = useState([]); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [backups, setBackups] = useState([]); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(''); - const [restoreTarget, setRestoreTarget] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); + const [restoreTarget, setRestoreTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const load = useCallback(async () => { setLoading(true); try { const [backupData, settingsData] = await Promise.all([ - api.adminBackups(), - api.adminBackupSettings(), + api.adminBackups() as Promise<{ backups?: Backup[] }>, + api.adminBackupSettings() as Promise>, ]); setBackups(backupData.backups || []); setSettings({ ...DEFAULT_SETTINGS, ...settingsData }); } catch (err) { - toast.error(err.message || 'Failed to load backups.'); + toast.error(errMessage(err, 'Failed to load backups.')); } finally { setLoading(false); } @@ -55,7 +73,7 @@ export default function BackupManagementCard() { useEffect(() => { load(); }, [load]); const latest = backups[0]; - const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value })); + const setSchedule = (key: keyof BackupSettings, value: string | boolean) => setSettings(prev => ({ ...prev, [key]: value })); async function handleCreate() { setBusy('create'); @@ -64,16 +82,16 @@ export default function BackupManagementCard() { toast.success('Backup created.'); await load(); } catch (err) { - toast.error(err.message || 'Failed to create backup.'); + toast.error(errMessage(err, 'Failed to create backup.')); } finally { setBusy(''); } } - async function handleDownload(backup) { + async function handleDownload(backup: Backup) { setBusy(`download:${backup.id}`); try { - const { blob, filename } = await api.downloadAdminBackup(backup.id); + const { blob, filename } = await api.downloadAdminBackup(backup.id) as { blob: Blob; filename?: string }; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -83,13 +101,13 @@ export default function BackupManagementCard() { a.remove(); URL.revokeObjectURL(url); } catch (err) { - toast.error(err.message || 'Failed to download backup.'); + toast.error(errMessage(err, 'Failed to download backup.')); } finally { setBusy(''); } } - async function handleImport(e) { + async function handleImport(e: React.ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; @@ -100,7 +118,7 @@ export default function BackupManagementCard() { toast.success('Backup imported.'); await load(); } catch (err) { - toast.error(err.message || 'Failed to import backup.'); + toast.error(errMessage(err, 'Failed to import backup.')); } finally { setBusy(''); } @@ -110,12 +128,12 @@ export default function BackupManagementCard() { if (!restoreTarget) return; setBusy(`restore:${restoreTarget.id}`); try { - const result = await api.restoreAdminBackup(restoreTarget.id); + const result = await api.restoreAdminBackup(restoreTarget.id) as { pre_restore_backup?: string }; toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`); setRestoreTarget(null); await load(); } catch (err) { - toast.error(err.message || 'Failed to restore backup.'); + toast.error(errMessage(err, 'Failed to restore backup.')); } finally { setBusy(''); } @@ -130,7 +148,7 @@ export default function BackupManagementCard() { setDeleteTarget(null); await load(); } catch (err) { - toast.error(err.message || 'Failed to delete backup.'); + toast.error(errMessage(err, 'Failed to delete backup.')); } finally { setBusy(''); } @@ -143,12 +161,12 @@ export default function BackupManagementCard() { enabled: !!settings.enabled, frequency: settings.frequency, time: settings.time, - retention_count: parseInt(settings.retention_count, 10) || 2, - }); + retention_count: parseInt(String(settings.retention_count), 10) || 2, + }) as Partial; setSettings({ ...DEFAULT_SETTINGS, ...saved }); toast.success('Backup schedule saved.'); } catch (err) { - toast.error(err.message || 'Failed to save backup schedule.'); + toast.error(errMessage(err, 'Failed to save backup schedule.')); } finally { setBusy(''); } @@ -161,7 +179,7 @@ export default function BackupManagementCard() { toast.success('Scheduled backup created.'); await load(); } catch (err) { - toast.error(err.message || 'Failed to run scheduled backup.'); + toast.error(errMessage(err, 'Failed to run scheduled backup.')); } finally { setBusy(''); } @@ -196,12 +214,12 @@ export default function BackupManagementCard() {

    - {[ + {([ ['Managed backups', backups.length], ['Latest backup', latest ? formatDateTime(latest.modified_at) : 'β€”'], ['Latest size', latest ? fmtBytes(latest.size_bytes) : 'β€”'], ['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'], - ].map(([label, value]) => ( + ] as [string, React.ReactNode][]).map(([label, value]) => (

    {label}

    {value}

    @@ -258,7 +276,7 @@ export default function BackupManagementCard() { {formatDateTime(backup.modified_at)} {fmtBytes(backup.size_bytes)} - + {backup.checksum || 'β€”'} diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.tsx similarity index 87% rename from client/components/admin/BankSyncAdminCard.jsx rename to client/components/admin/BankSyncAdminCard.tsx index 0106c43..839813f 100644 --- a/client/components/admin/BankSyncAdminCard.jsx +++ b/client/components/admin/BankSyncAdminCard.tsx @@ -1,30 +1,50 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { AlertTriangle, Info } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Toggle } from './adminShared'; -function parseUtc(str) { +interface BankSyncWorker { + running?: boolean; + last_run_at?: string | null; + next_run_at?: string | null; +} + +interface BankSyncConfig { + enabled?: boolean; + sync_interval_hours?: number; + sync_days?: number; + debug_logging?: boolean; + sync_days_max?: number; + seed_days?: number; + worker?: BankSyncWorker; + encryption_key_source?: string; +} + +function parseUtc(str: string | null | undefined): Date | null { if (!str) return null; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; return new Date(normalized); } -function timeAgo(iso) { - if (!iso) return null; - const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000); +function timeAgo(iso: string | null | undefined): string | null { + const d = parseUtc(iso); + if (!d) return null; + const secs = Math.floor((Date.now() - d.getTime()) / 1000); if (secs < 60) return 'just now'; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; return `${Math.floor(secs / 86400)}d ago`; } -function timeUntil(iso) { - if (!iso) return null; - const secs = Math.floor((parseUtc(iso).getTime() - Date.now()) / 1000); +function timeUntil(iso: string | null | undefined): string | null { + const d = parseUtc(iso); + if (!d) return null; + const secs = Math.floor((d.getTime() - Date.now()) / 1000); if (secs <= 0) return 'soon'; if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m`; @@ -32,31 +52,32 @@ function timeUntil(iso) { } export default function BankSyncAdminCard() { - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); const [enabled, setEnabled] = useState(false); - const [syncInterval, setSyncInterval] = useState(4); - const [syncDays, setSyncDays] = useState(30); + const [syncInterval, setSyncInterval] = useState(4); + const [syncDays, setSyncDays] = useState(30); const [debugLogging, setDebugLogging] = useState(false); useEffect(() => { api.bankSyncConfig() - .then(d => { + .then(raw => { + const d = raw as BankSyncConfig; setConfig(d); setEnabled(!!d.enabled); setSyncInterval(d.sync_interval_hours ?? 4); setSyncDays(d.sync_days ?? 30); setDebugLogging(!!d.debug_logging); }) - .catch(err => setLoadError(err.message || 'Failed to load bank sync config')) + .catch(err => setLoadError(errMessage(err, 'Failed to load bank sync config'))) .finally(() => setLoading(false)); }, []); const handleSave = async () => { - const hours = parseFloat(syncInterval); - const days = parseInt(syncDays, 10); + const hours = parseFloat(String(syncInterval)); + const days = parseInt(String(syncDays), 10); const maxDays = config?.sync_days_max ?? 45; if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) { toast.error('Sync interval must be between 0.5 and 168 hours.'); @@ -68,7 +89,7 @@ export default function BankSyncAdminCard() { } setSaving(true); try { - const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging }); + const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging }) as BankSyncConfig; setConfig(result); setEnabled(!!result.enabled); setSyncInterval(result.sync_interval_hours ?? 4); @@ -76,7 +97,7 @@ export default function BankSyncAdminCard() { setDebugLogging(!!result.debug_logging); toast.success('Bank sync settings saved.'); } catch (err) { - toast.error(err.message || 'Failed to update bank sync setting.'); + toast.error(errMessage(err, 'Failed to update bank sync setting.')); } finally { setSaving(false); } @@ -103,8 +124,8 @@ export default function BankSyncAdminCard() { } const changed = enabled !== !!config?.enabled - || parseFloat(syncInterval) !== config?.sync_interval_hours - || parseInt(syncDays, 10) !== config?.sync_days + || parseFloat(String(syncInterval)) !== config?.sync_interval_hours + || parseInt(String(syncDays), 10) !== config?.sync_days || debugLogging !== !!config?.debug_logging; const worker = config?.worker; const seedDays = config?.seed_days ?? 44; @@ -209,7 +230,7 @@ export default function BankSyncAdminCard() {
    {/* Amber warning at the SimpleFIN limit */} - {parseInt(syncDays, 10) >= maxDays && ( + {parseInt(String(syncDays), 10) >= maxDays && (

    diff --git a/client/components/admin/CleanupPanel.jsx b/client/components/admin/CleanupPanel.tsx similarity index 78% rename from client/components/admin/CleanupPanel.jsx rename to client/components/admin/CleanupPanel.tsx index b9f4f97..6a0e849 100644 --- a/client/components/admin/CleanupPanel.jsx +++ b/client/components/admin/CleanupPanel.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Play, Wrench } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; @@ -9,9 +10,42 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FieldRow, Toggle, formatDateTime } from './adminShared'; +interface CleanupForm { + import_sessions_enabled: boolean; + temp_exports_enabled: boolean; + temp_export_max_age_hours: number; + backup_partials_enabled: boolean; + import_history_enabled: boolean; + import_history_max_age_days: number; +} + +interface CleanupTask { + pruned?: number; + removed?: number; +} + +interface CleanupResult { + import_sessions?: CleanupTask; + temp_export_files?: CleanupTask; + backup_partials?: CleanupTask; + import_history?: CleanupTask; +} + +interface CleanupStatus { + last_run_at?: string | null; + last_result?: CleanupResult | null; + settings?: CleanupForm; +} + +const TASK_TOGGLES: [keyof CleanupForm, string][] = [ + ['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'], + ['temp_exports_enabled', 'Remove stale SQLite export temp files'], + ['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'], +]; + export default function CleanupPanel() { - const [status, setStatus] = useState(null); - const [form, setForm] = useState({ + const [status, setStatus] = useState(null); + const [form, setForm] = useState({ import_sessions_enabled: true, temp_exports_enabled: true, temp_export_max_age_hours: 2, @@ -25,11 +59,11 @@ export default function CleanupPanel() { const load = useCallback(async () => { try { - const data = await api.adminCleanup(); + const data = await api.adminCleanup() as CleanupStatus; setStatus(data); if (data.settings) setForm(data.settings); } catch (err) { - toast.error(err.message || 'Failed to load cleanup settings.'); + toast.error(errMessage(err, 'Failed to load cleanup settings.')); } finally { setLoading(false); } @@ -37,16 +71,16 @@ export default function CleanupPanel() { useEffect(() => { load(); }, [load]); - const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); + const set = (k: keyof CleanupForm, v: boolean | number) => setForm(prev => ({ ...prev, [k]: v }) as CleanupForm); async function handleSave() { setSaving(true); try { - const next = await api.saveAdminCleanup(form); + const next = await api.saveAdminCleanup(form) as CleanupForm | null; if (next) setForm(next); toast.success('Cleanup settings saved.'); } catch (err) { - toast.error(err.message || 'Failed to save cleanup settings.'); + toast.error(errMessage(err, 'Failed to save cleanup settings.')); } finally { setSaving(false); } @@ -55,7 +89,7 @@ export default function CleanupPanel() { async function handleRunNow() { setRunning(true); try { - const result = await api.runAdminCleanup(); + const result = await api.runAdminCleanup() as { ran_at?: string; tasks?: CleanupResult }; setStatus(prev => ({ ...prev, last_run_at: result.ran_at, @@ -63,13 +97,13 @@ export default function CleanupPanel() { })); toast.success('Cleanup tasks completed.'); } catch (err) { - toast.error(err.message || 'Cleanup run failed.'); + toast.error(errMessage(err, 'Cleanup run failed.')); } finally { setRunning(false); } } - function resultLine(label, task, countKey) { + function resultLine(label: string, task: CleanupTask | undefined, countKey: keyof CleanupTask): string | null { if (!task || task[countKey] == null) return null; return `${label}: ${task[countKey]}`; } @@ -137,11 +171,7 @@ export default function CleanupPanel() {

    Task Settings

    - {[ - ['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'], - ['temp_exports_enabled', 'Remove stale SQLite export temp files'], - ['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'], - ].map(([key, label]) => ( + {TASK_TOGGLES.map(([key, label]) => ( set(key, v)} label={label} /> diff --git a/client/components/admin/EmailNotifCard.jsx b/client/components/admin/EmailNotifCard.tsx similarity index 86% rename from client/components/admin/EmailNotifCard.jsx rename to client/components/admin/EmailNotifCard.tsx index 45ea319..cf61a7f 100644 --- a/client/components/admin/EmailNotifCard.jsx +++ b/client/components/admin/EmailNotifCard.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Eye, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; @@ -10,18 +11,32 @@ import { } from '@/components/ui/select'; import { SectionHeading, FieldRow, Toggle } from './adminShared'; -export default function EmailNotifCard() { - const DEFAULTS = { - enabled: false, - sender_name: '', sender_address: '', - smtp_host: '', smtp_port: '587', smtp_encryption: 'starttls', - smtp_self_signed: false, - smtp_username: '', smtp_password: '', - allow_user_config: false, - global_recipient: '', - }; +interface EmailConfig { + enabled: boolean; + sender_name: string; + sender_address: string; + smtp_host: string; + smtp_port: string; + smtp_encryption: string; + smtp_self_signed: boolean; + smtp_username: string; + smtp_password: string; + allow_user_config: boolean; + global_recipient: string; +} - const [cfg, setCfg] = useState(DEFAULTS); +const DEFAULTS: EmailConfig = { + enabled: false, + sender_name: '', sender_address: '', + smtp_host: '', smtp_port: '587', smtp_encryption: 'starttls', + smtp_self_signed: false, + smtp_username: '', smtp_password: '', + allow_user_config: false, + global_recipient: '', +}; + +export default function EmailNotifCard() { + const [cfg, setCfg] = useState(DEFAULTS); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); @@ -31,12 +46,12 @@ export default function EmailNotifCard() { useEffect(() => { api.notifAdmin() - .then(d => setCfg({ ...DEFAULTS, ...d })) - .catch(err => setLoadError(err.message || 'Failed to load email settings')) + .then(d => setCfg({ ...DEFAULTS, ...(d as Partial) })) + .catch(err => setLoadError(errMessage(err, 'Failed to load email settings'))) .finally(() => setLoading(false)); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); - const set = (k, v) => setCfg(p => ({ ...p, [k]: v })); + const set = (k: keyof EmailConfig, v: string | boolean) => setCfg(p => ({ ...p, [k]: v }) as EmailConfig); const handleSave = async () => { setSaving(true); @@ -44,7 +59,7 @@ export default function EmailNotifCard() { await api.saveNotifAdmin(cfg); toast.success('Email settings saved.'); } catch (err) { - toast.error(err.message || 'Failed to save.'); + toast.error(errMessage(err, 'Failed to save.')); } finally { setSaving(false); } @@ -57,7 +72,7 @@ export default function EmailNotifCard() { await api.testEmail({ to: testEmail }); toast.success('Test email sent.'); } catch (err) { - toast.error(err.message || 'Failed to send test email.'); + toast.error(errMessage(err, 'Failed to send test email.')); } finally { setTesting(false); } diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.tsx similarity index 92% rename from client/components/admin/LoginModeCard.jsx rename to client/components/admin/LoginModeCard.tsx index 7156c2c..20ca2c7 100644 --- a/client/components/admin/LoginModeCard.jsx +++ b/client/components/admin/LoginModeCard.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; @@ -15,9 +15,15 @@ import { } from '@/components/ui/alert-dialog'; import { LogIn, UserCheck, ShieldCheck } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { AdminUser } from './UsersTable'; -export default function LoginModeCard({ users, onModeChange }) { - const [modeData, setModeData] = useState(null); +interface AuthModeConfig { + auth_mode?: string; + default_user_id?: number | null; +} + +export default function LoginModeCard({ users, onModeChange }: { users?: AdminUser[]; onModeChange?: (mode: string) => void }) { + const [modeData, setModeData] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); @@ -27,25 +33,26 @@ export default function LoginModeCard({ users, onModeChange }) { useEffect(() => { api.authModeConfig() - .then(d => { + .then(raw => { + const d = raw as AuthModeConfig; setModeData(d); const mode = d.auth_mode === 'single' ? 'single' : 'multi'; setSelected(mode); setSelectedUser(d.default_user_id?.toString() || ''); onModeChange?.(mode); }) - .catch(err => setLoadError(err.message || 'Failed to load login mode config')) + .catch(err => setLoadError(errMessage(err, 'Failed to load login mode config'))) .finally(() => setLoading(false)); }, []); // eslint-disable-line - const doSetMode = async (mode, userId) => { + const doSetMode = async (mode: string, userId: string | null) => { setSaving(true); try { await api.setAuthMode({ auth_mode: mode, - default_user_id: mode === 'single' ? parseInt(userId, 10) : null, + default_user_id: mode === 'single' ? parseInt(userId || '', 10) : null, }); - const d = await api.authModeConfig(); + const d = await api.authModeConfig() as AuthModeConfig; setModeData(d); const resolved = d.auth_mode === 'single' ? 'single' : 'multi'; setSelected(resolved); @@ -53,7 +60,7 @@ export default function LoginModeCard({ users, onModeChange }) { onModeChange?.(resolved); toast.success(mode === 'single' ? 'No Login mode enabled.' : 'Login requirement restored.'); } catch (err) { - toast.error(err.message || 'Failed to update login mode.'); + toast.error(errMessage(err, 'Failed to update login mode.')); // revert UI selection on error setSelected(modeData?.auth_mode === 'single' ? 'single' : 'multi'); } finally { diff --git a/client/components/admin/OnboardingWizard.jsx b/client/components/admin/OnboardingWizard.tsx similarity index 95% rename from client/components/admin/OnboardingWizard.jsx rename to client/components/admin/OnboardingWizard.tsx index 7944df2..a870e42 100644 --- a/client/components/admin/OnboardingWizard.jsx +++ b/client/components/admin/OnboardingWizard.tsx @@ -1,13 +1,14 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -export default function OnboardingWizard({ onComplete }) { +export default function OnboardingWizard({ onComplete }: { onComplete: () => void }) { const [step, setStep] = useState(0); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -15,7 +16,7 @@ export default function OnboardingWizard({ onComplete }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const handleCreate = async (e) => { + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -38,7 +39,7 @@ export default function OnboardingWizard({ onComplete }) { toast.success('User created successfully.'); onComplete(); } catch (err) { - const errorMessage = err.message || 'Failed to create user.'; + const errorMessage = errMessage(err, 'Failed to create user.'); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/client/components/admin/PrivacyAdminCard.jsx b/client/components/admin/PrivacyAdminCard.jsx deleted file mode 100644 index 2fffc99..0000000 --- a/client/components/admin/PrivacyAdminCard.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { toast } from 'sonner'; -import { api } from '@/api'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { FieldRow, Toggle } from './adminShared'; - -export default function PrivacyAdminCard() { - const [geoEnabled, setGeoEnabled] = useState(false); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - useEffect(() => { - api.privacySettings() - .then(d => { setGeoEnabled(!!d.geolocation_enabled); }) - .catch(() => toast.error('Failed to load privacy settings')) - .finally(() => setLoading(false)); - }, []); - - async function toggleGeo(next) { - setSaving(true); - try { - const d = await api.setPrivacySettings({ geolocation_enabled: next }); - setGeoEnabled(!!d.geolocation_enabled); - toast.success(next ? 'Geolocation enabled' : 'Geolocation disabled'); - } catch { - toast.error('Failed to update privacy settings'); - } finally { - setSaving(false); - } - } - - return ( - - - Privacy - - - -
    - - - {geoEnabled ? 'On' : 'Off (default)'} - -
    -
    -

    - When enabled, new-device logins resolve the login IP to a city/region via{' '} - ip-api.com over plain HTTP. Disable to keep - all login data on-device. Location data is encrypted at rest. -

    -
    -
    - ); -} diff --git a/client/components/admin/UsersTable.jsx b/client/components/admin/UsersTable.tsx similarity index 86% rename from client/components/admin/UsersTable.jsx rename to client/components/admin/UsersTable.tsx index 2c74c27..844560e 100644 --- a/client/components/admin/UsersTable.jsx +++ b/client/components/admin/UsersTable.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; @@ -11,19 +11,41 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; +import type { User } from '@/hooks/useAuth'; -export default function UsersTable({ users, onRefresh, currentUser }) { - const [resetForms, setResetForms] = useState({}); - const [deleting, setDeleting] = useState(null); - const [resetting, setResetting] = useState(null); - const [roleUpdating, setRoleUpdating] = useState(null); - const [activeUpdating, setActiveUpdating] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); +export interface AdminUser { + id: number; + username: string; + role?: string; + is_default_admin?: boolean; + active?: boolean | number; + must_change_password?: boolean; + [key: string]: unknown; +} - const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } })); - const getForm = (id) => resetForms[id] || { pw: '', open: false }; +interface ResetForm { + pw?: string; + open?: boolean; +} - const handleReset = async (user) => { +interface UsersTableProps { + users?: AdminUser[]; + onRefresh: () => void; + currentUser?: User | null; +} + +export default function UsersTable({ users, onRefresh, currentUser }: UsersTableProps) { + const [resetForms, setResetForms] = useState>({}); + const [deleting, setDeleting] = useState(null); + const [resetting, setResetting] = useState(null); + const [roleUpdating, setRoleUpdating] = useState(null); + const [activeUpdating, setActiveUpdating] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const setReset = (id: number, v: ResetForm) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } })); + const getForm = (id: number): ResetForm => resetForms[id] || { pw: '', open: false }; + + const handleReset = async (user: AdminUser) => { const form = getForm(user.id); if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; } setResetting(user.id); @@ -32,7 +54,7 @@ export default function UsersTable({ users, onRefresh, currentUser }) { toast.success(`Password reset for ${user.username}.`); setReset(user.id, { pw: '', open: false }); } catch (err) { - toast.error(err.message || 'Failed to reset password.'); + toast.error(errMessage(err, 'Failed to reset password.')); } finally { setResetting(null); } @@ -47,13 +69,13 @@ export default function UsersTable({ users, onRefresh, currentUser }) { setDeleteTarget(null); onRefresh(); } catch (err) { - toast.error(err.message || 'Failed to delete user.'); + toast.error(errMessage(err, 'Failed to delete user.')); } finally { setDeleting(null); } }; - const handleRoleChange = async (user, role) => { + const handleRoleChange = async (user: AdminUser, role: string) => { if (user.role === role) return; setRoleUpdating(user.id); try { @@ -61,20 +83,20 @@ export default function UsersTable({ users, onRefresh, currentUser }) { toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`); onRefresh(); } catch (err) { - toast.error(err.message || 'Failed to update user role.'); + toast.error(errMessage(err, 'Failed to update user role.')); } finally { setRoleUpdating(null); } }; - const handleActiveChange = async (user, active) => { + const handleActiveChange = async (user: AdminUser, active: boolean) => { setActiveUpdating(user.id); try { await api.updateUserActive(user.id, { active }); toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`); onRefresh(); } catch (err) { - toast.error(err.message || 'Failed to update user status.'); + toast.error(errMessage(err, 'Failed to update user status.')); } finally { setActiveUpdating(null); } diff --git a/client/components/admin/adminShared.jsx b/client/components/admin/adminShared.tsx similarity index 73% rename from client/components/admin/adminShared.jsx rename to client/components/admin/adminShared.tsx index 0c99fb2..6cfd98f 100644 --- a/client/components/admin/adminShared.jsx +++ b/client/components/admin/adminShared.tsx @@ -1,12 +1,12 @@ -import React from 'react'; +import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; -export function SectionHeading({ children }) { +export function SectionHeading({ children }: { children?: ReactNode }) { return

    {children}

    ; } -export function FieldRow({ label, children }) { +export function FieldRow({ label, children }: { label?: ReactNode; children?: ReactNode }) { return (
    @@ -15,7 +15,12 @@ export function FieldRow({ label, children }) { ); } -export function Toggle({ checked, onChange, label, disabled = false }) { +export function Toggle({ checked, onChange, label, disabled = false }: { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; + disabled?: boolean; +}) { return ( @@ -155,7 +183,7 @@ export default function UnmatchDialogs({ size="sm" variant="outline" className="h-6 px-2 text-xs" - onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))} + onClick={() => setBulkUnmatch(p => p ? { ...p, checkedIds: new Set() } : p)} > None @@ -179,8 +207,9 @@ export default function UnmatchDialogs({ checked={bulkUnmatch.checkedIds.has(tx.id)} onCheckedChange={checked => { setBulkUnmatch(p => { + if (!p) return p; const next = new Set(p.checkedIds); - checked ? next.add(tx.id) : next.delete(tx.id); + if (checked) next.add(tx.id); else next.delete(tx.id); return { ...p, checkedIds: next }; }); }} @@ -196,7 +225,7 @@ export default function UnmatchDialogs({ 'shrink-0 font-mono text-sm font-semibold tabular-nums', Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600', )}> - {fmtTransactionAmount(tx.amount, tx.currency)} + {fmtTransactionAmount(tx.amount, tx.currency ?? undefined)}

    ))} @@ -213,7 +242,7 @@ export default function UnmatchDialogs({ className="mt-0.5" checked={bulkUnmatch.removeRuleId === rule.id} onCheckedChange={checked => - setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null })) + setBulkUnmatch(p => p ? { ...p, removeRuleId: checked ? rule.id : null } : p) } />
    diff --git a/client/components/bill-modal/transactionDisplay.js b/client/components/bill-modal/transactionDisplay.ts similarity index 58% rename from client/components/bill-modal/transactionDisplay.js rename to client/components/bill-modal/transactionDisplay.ts index 9c24b92..7ddace9 100644 --- a/client/components/bill-modal/transactionDisplay.js +++ b/client/components/bill-modal/transactionDisplay.ts @@ -4,25 +4,33 @@ import { formatCentsUSD } from '@/lib/money'; // the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee // grouping. -export function fmtTransactionAmount(amount, currency = 'USD') { +interface TxLike { + payee?: string | null; + description?: string | null; + memo?: string | null; + posted_date?: string | null; + transacted_at?: string | number | null; +} + +export function fmtTransactionAmount(amount: Parameters[0], currency = 'USD') { return formatCentsUSD(amount, { signed: true, currency }); } -export function transactionDate(tx) { +export function transactionDate(tx: TxLike | null | undefined): string | null { return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null; } -export function transactionTitle(tx) { +export function transactionTitle(tx: TxLike | null | undefined): string { return tx?.payee || tx?.description || tx?.memo || 'Transaction'; } -export function normalizePayee(s) { +export function normalizePayee(s: string | null | undefined): string { return (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } // Two payees are "similar" when one normalized name is a prefix of the other // (min 3 chars) β€” the grouping used to find related matches to unmatch together. -export function isSimilarPayee(a, b) { +export function isSimilarPayee(a: string | null | undefined, b: string | null | undefined): boolean { const na = normalizePayee(a); const nb = normalizePayee(b); const minLen = Math.min(na.length, nb.length); diff --git a/client/components/data/AutoMatchReview.jsx b/client/components/data/AutoMatchReview.tsx similarity index 82% rename from client/components/data/AutoMatchReview.jsx rename to client/components/data/AutoMatchReview.tsx index 38e39f0..1004bf2 100644 --- a/client/components/data/AutoMatchReview.jsx +++ b/client/components/data/AutoMatchReview.tsx @@ -1,36 +1,46 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Undo2, ChevronDown, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { formatUSD } from '@/lib/money'; +import { cn, errMessage } from '@/lib/utils'; +import { formatUSD, type Dollars } from '@/lib/money'; -function fmtAmt(dollars) { +interface AutoMatchItem { + id: number; + transaction_id?: number; + payee?: string | null; + description?: string | null; + bill_name?: string; + paid_date?: string | null; + amount: Dollars; +} + +function fmtAmt(dollars: Dollars) { return formatUSD(dollars, { dash: true }); } -function fmtDate(iso) { +function fmtDate(iso: string | null | undefined) { if (!iso) return ''; const d = new Date(`${iso}T00:00:00`); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } -function payeeLabel(row) { +function payeeLabel(row: AutoMatchItem) { return row.payee || row.description || `Transaction #${row.transaction_id}`; } -export default function AutoMatchReview({ refreshKey }) { - const [items, setItems] = useState([]); +export default function AutoMatchReview({ refreshKey }: { refreshKey?: number | string }) { + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(true); - const [undoing, setUndoing] = useState(null); + const [undoing, setUndoing] = useState(null); const load = useCallback(async () => { setLoading(true); try { const rows = await api.recentAutoMatched(); - setItems(Array.isArray(rows) ? rows : []); + setItems(Array.isArray(rows) ? rows as AutoMatchItem[] : []); } catch { // Non-blocking β€” don't surface errors for a review panel } finally { @@ -40,14 +50,14 @@ export default function AutoMatchReview({ refreshKey }) { useEffect(() => { load(); }, [load, refreshKey]); - async function handleUndo(item) { + async function handleUndo(item: AutoMatchItem) { setUndoing(item.id); try { await api.undoAutoMatch(item.id); setItems(prev => prev.filter(p => p.id !== item.id)); toast.success(`Undone β€” ${payeeLabel(item)} unlinked from "${item.bill_name}"`); } catch (err) { - toast.error(err.message || 'Failed to undo auto-match'); + toast.error(errMessage(err, 'Failed to undo auto-match')); } finally { setUndoing(null); } diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.tsx similarity index 86% rename from client/components/data/BankSyncSection.jsx rename to client/components/data/BankSyncSection.tsx index 3a5b0ef..7caf0d8 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.tsx @@ -1,12 +1,12 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Building2, ChevronDown, ChevronRight, Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink, } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; -import { formatUSD, formatCentsUSD } from '@/lib/money'; +import { cn, errMessage } from '@/lib/utils'; +import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; @@ -19,10 +19,62 @@ import { } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { SectionCard } from './dataShared'; +import { SectionCard, type SectionCardProps } from './dataShared'; import AutoMatchReview from './AutoMatchReview'; +import type { Bill } from '@/types'; -function TokenInput({ value, onChange, disabled }) { +interface BankTx { + id: number | string; + posted_date?: string | null; + transacted_at?: string | null; + payee?: string | null; + description?: string | null; + amount: Cents; + match_status?: string | null; + matched_bill_id?: number | null; + matched_bill_name?: string | null; +} + +interface BankAccount { + id: number | string; + name: string; + org_name?: string | null; + monitored?: boolean; + balance: Cents; + transaction_count?: number; + transactions: BankTx[]; +} + +interface Connection { + id: number | string; + name?: string; + provider?: string; + status?: string; + last_error?: string | null; + last_sync_at?: string | null; + account_count?: number; + transaction_count?: number; +} + +interface FinAccount { + id: number | string; + name: string; + org_name?: string | null; + balance_dollars?: Dollars | null; +} + +interface BtPatch { + enabled?: boolean; + accountId?: string; + pendingDays?: number; + lateGraceDays?: number; +} + +function TokenInput({ value, onChange, disabled }: { + value: string; + onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; +}) { const [show, setShow] = useState(false); const tail = value.slice(-4); return ( @@ -57,31 +109,32 @@ function TokenInput({ value, onChange, disabled }) { ); } -function parseUtc(str) { +function parseUtc(str: string | null | undefined): Date | null { if (!str) return null; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; return new Date(normalized); } -function fmtDate(iso) { - if (!iso) return 'β€”'; - return parseUtc(iso).toLocaleString(undefined, { +function fmtDate(iso: string | null | undefined): string { + const d = parseUtc(iso); + if (!d) return 'β€”'; + return d.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } -function fmtShortDate(date) { +function fmtShortDate(date: string | null | undefined): string { if (!date) return 'β€”'; const d = new Date(`${date}T00:00:00`); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } -function fmtDollars(cents) { +function fmtDollars(cents: Cents): string { return formatCentsUSD(cents, { dash: true }); } -function MatchBadge({ status, billName }) { +function MatchBadge({ status, billName }: { status: string; billName?: string | null }) { if (status === 'matched') { return ( @@ -95,9 +148,16 @@ function MatchBadge({ status, billName }) { return unmatched; } -function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) { +function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }: { + open: boolean; + onClose: () => void; + transaction?: BankTx | null; + bills: Bill[]; + onConfirm: (billId: number | string | null) => void; + busy: boolean; +}) { const [search, setSearch] = useState(''); - const [selectedId, setSelectedId] = useState(null); + const [selectedId, setSelectedId] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); @@ -165,7 +225,18 @@ function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy } ); } -function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) { +interface AccountRowProps { + account: BankAccount; + expanded: boolean; + onToggleExpand: () => void; + onToggleMonitored: (accountId: number | string, monitored: boolean) => void; + toggling: boolean; + onMatch: (tx: BankTx) => void; + onUnmatch: (tx: BankTx) => void; + matchingTxId: number | string | null; +} + +function AccountRow({ account, expanded, onToggleExpand, onToggleMonitored, toggling, onMatch, onUnmatch, matchingTxId }: AccountRowProps) { const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); return ( @@ -183,7 +254,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit {/* Monitored toggle */}
    e.stopPropagation()}> onToggleMonitored(account.id, v)} disabled={toggling} aria-label={`Monitor ${account.name}`} @@ -292,27 +363,30 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit ); } -export default function BankSyncSection({ onConnectionChange, cardProps = {} }) { - const [enabled, setEnabled] = useState(null); +export default function BankSyncSection({ onConnectionChange, cardProps = {} }: { + onConnectionChange?: (conn: Connection | null) => void; + cardProps?: Partial; +}) { + const [enabled, setEnabled] = useState(null); const [syncDays, setSyncDays] = useState(30); const [seedDays, setSeedDays] = useState(44); - const [serverTz, setServerTz] = useState(null); - const [connections, setConnections] = useState([]); - const [accountsBySource, setAccountsBySource] = useState({}); - const [accountsLoading, setAccountsLoading] = useState({}); - const [accountsErrorBySource, setAccountsError] = useState({}); + const [serverTz, setServerTz] = useState(null); + const [connections, setConnections] = useState([]); + const [accountsBySource, setAccountsBySource] = useState>({}); + const [accountsLoading, setAccountsLoading] = useState>({}); + const [accountsErrorBySource, setAccountsError] = useState>({}); const [loadError, setLoadError] = useState(''); const [setupToken, setSetupToken] = useState(''); const [connecting, setConnecting] = useState(false); - const [syncing, setSyncing] = useState(null); - const [backfilling, setBackfilling] = useState(null); - const [disconnectTarget, setDisconnectTarget] = useState(null); + const [syncing, setSyncing] = useState(null); + const [backfilling, setBackfilling] = useState(null); + const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnecting, setDisconnecting] = useState(false); - const [expandedAccount, setExpandedAccount] = useState(null); - const [togglingAccount, setTogglingAccount] = useState(null); - const [bills, setBills] = useState([]); - const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } - const [matchingTxId, setMatchingTxId] = useState(null); + const [expandedAccount, setExpandedAccount] = useState(null); + const [togglingAccount, setTogglingAccount] = useState(null); + const [bills, setBills] = useState([]); + const [matchTarget, setMatchTarget] = useState<{ sourceId: number | string; tx: BankTx } | null>(null); + const [matchingTxId, setMatchingTxId] = useState(null); const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0); // Bank tracking state @@ -320,22 +394,22 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) const [btAccountId, setBtAccountId] = useState(''); const [btPendingDays, setBtPendingDays] = useState(3); const [btLateGraceDays, setBtLateGraceDays] = useState(0); - const [btAccounts, setBtAccounts] = useState([]); + const [btAccounts, setBtAccounts] = useState([]); const [btSaving, setBtSaving] = useState(false); // Auto-categorization state const [acmEnabled, setAcmEnabled] = useState(true); const [acmSaving, setAcmSaving] = useState(false); - const loadAccounts = useCallback(async (conns) => { + const loadAccounts = useCallback(async (conns: Connection[]) => { for (const conn of conns) { setAccountsLoading(prev => ({ ...prev, [conn.id]: true })); setAccountsError(prev => ({ ...prev, [conn.id]: '' })); try { - const accounts = await api.dataSourceAccounts(conn.id); + const accounts = await api.dataSourceAccounts(conn.id) as BankAccount[]; setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts })); } catch (err) { - setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' })); + setAccountsError(prev => ({ ...prev, [conn.id]: errMessage(err, 'Failed to load accounts') })); } finally { setAccountsLoading(prev => ({ ...prev, [conn.id]: false })); } @@ -346,21 +420,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) const loadBankTracking = useCallback(async () => { try { const [settings, accounts] = await Promise.all([ - api.settings(), + api.settings() as Promise>, api.allFinancialAccounts().catch(() => []), ]); setBtEnabled(settings.bank_tracking_enabled === 'true'); setBtAccountId(settings.bank_tracking_account_id || ''); - setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3); - setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0); - setBtAccounts(Array.isArray(accounts) ? accounts : []); + setBtPendingDays(parseInt(settings.bank_tracking_pending_days || '', 10) || 3); + setBtLateGraceDays(parseInt(settings.bank_late_attribution_days || '', 10) || 0); + setBtAccounts(Array.isArray(accounts) ? accounts as FinAccount[] : []); setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false'); } catch { // non-fatal β€” bank tracking section just won't populate } }, []); - const handleBtSave = useCallback(async (patch) => { + const handleBtSave = useCallback(async (patch: BtPatch) => { setBtSaving(true); try { const next = { @@ -376,20 +450,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays); toast.success('Bank tracking settings saved'); } catch (err) { - toast.error(err.message || 'Failed to save bank tracking settings'); + toast.error(errMessage(err, 'Failed to save bank tracking settings')); } finally { setBtSaving(false); } }, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]); - const handleAcmSave = useCallback(async (next) => { + const handleAcmSave = useCallback(async (next: boolean) => { setAcmSaving(true); try { await api.saveSettings({ bank_auto_categorize_merchants: String(next) }); setAcmEnabled(next); toast.success('Auto-categorization setting saved'); } catch (err) { - toast.error(err.message || 'Failed to save auto-categorization setting'); + toast.error(errMessage(err, 'Failed to save auto-categorization setting')); } finally { setAcmSaving(false); } @@ -399,20 +473,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setLoadError(''); try { const [status, sources] = await Promise.all([ - api.simplefinStatus(), + api.simplefinStatus() as Promise<{ enabled?: boolean; sync_days?: number; seed_days?: number; timezone?: string | null }>, api.dataSources({ type: 'provider_sync' }), ]); - setEnabled(status.enabled); + setEnabled(!!status.enabled); setSyncDays(status.sync_days ?? 30); setSeedDays(status.seed_days ?? 44); setServerTz(status.timezone || null); - const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; + const conns = Array.isArray(sources) ? (sources as Connection[]).filter(s => s.provider === 'simplefin') : []; setConnections(conns); onConnectionChange?.(conns[0] || null); if (conns.length > 0) loadAccounts(conns); } catch (err) { setEnabled(false); - setLoadError(err.message || 'Failed to load bank sync status'); + setLoadError(errMessage(err, 'Failed to load bank sync status')); } }, [onConnectionChange, loadAccounts]); @@ -426,7 +500,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) } }, [connections, bills.length]); - function updateTxInState(sourceId, txId, updates) { + function updateTxInState(sourceId: number | string, txId: number | string, updates: Partial) { setAccountsBySource(prev => ({ ...prev, [sourceId]: (prev[sourceId] || []).map(acc => ({ @@ -436,14 +510,14 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) })); } - const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx }); + const handleMatch = (sourceId: number | string, tx: BankTx) => setMatchTarget({ sourceId, tx }); - const handleConfirmMatch = async (billId) => { + const handleConfirmMatch = async (billId: number | string | null) => { if (!matchTarget || !billId) return; const { sourceId, tx } = matchTarget; setMatchingTxId(tx.id); try { - const { transaction } = await api.confirmTransactionMatch(tx.id, billId); + const { transaction } = await api.confirmTransactionMatch(tx.id, billId) as { transaction: BankTx }; updateTxInState(sourceId, tx.id, { match_status: transaction.match_status, matched_bill_id: transaction.matched_bill_id, @@ -452,20 +526,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setMatchTarget(null); toast.success(`Matched to "${transaction.matched_bill_name}" β€” payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`); } catch (err) { - toast.error(err.message || 'Failed to match transaction.'); + toast.error(errMessage(err, 'Failed to match transaction.')); } finally { setMatchingTxId(null); } }; - const handleUnmatch = async (sourceId, tx) => { + const handleUnmatch = async (sourceId: number | string, tx: BankTx) => { setMatchingTxId(tx.id); try { await api.unmatchTransaction(tx.id); updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null }); toast.success('Match removed.'); } catch (err) { - toast.error(err.message || 'Failed to remove match.'); + toast.error(errMessage(err, 'Failed to remove match.')); } finally { setMatchingTxId(null); } @@ -476,21 +550,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; } setConnecting(true); try { - const result = await api.connectSimplefin(token); + const result = await api.connectSimplefin(token) as { accountsUpserted?: number; transactionsNew?: number }; toast.success(`Connected β€” ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`); setSetupToken(''); await load(); } catch (err) { - toast.error(err.message || 'Failed to connect SimpleFIN'); + toast.error(errMessage(err, 'Failed to connect SimpleFIN')); } finally { setConnecting(false); } }; - const handleSync = async (id) => { + const handleSync = async (id: number | string) => { setSyncing(id); try { - const result = await api.syncDataSource(id); + const result = await api.syncDataSource(id) as { errlist?: string; transactionsNew?: number }; if (result.errlist) { toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`); } else { @@ -499,17 +573,17 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { - toast.error(err.message || 'Sync failed'); + toast.error(errMessage(err, 'Sync failed')); await load(); } finally { setSyncing(null); } }; - const handleBackfill = async (id) => { + const handleBackfill = async (id: number | string) => { setBackfilling(id); try { - const result = await api.backfillDataSource(id); + const result = await api.backfillDataSource(id) as { errlist?: string; transactionsNew?: number }; if (result.errlist) { toast.warning(`Backfill complete β€” ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`); } else { @@ -518,7 +592,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { - toast.error(err.message || 'Backfill failed'); + toast.error(errMessage(err, 'Backfill failed')); await load(); } finally { setBackfilling(null); @@ -534,13 +608,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setDisconnectTarget(null); await load(); } catch (err) { - toast.error(err.message || 'Failed to disconnect'); + toast.error(errMessage(err, 'Failed to disconnect')); } finally { setDisconnecting(false); } }; - const handleToggleMonitored = async (sourceId, accountId, monitored) => { + const handleToggleMonitored = async (sourceId: number | string, accountId: number | string, monitored: boolean) => { setTogglingAccount(accountId); // Optimistic update setAccountsBySource(prev => ({ @@ -559,13 +633,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) a.id === accountId ? { ...a, monitored: !monitored } : a ), })); - toast.error(err.message || 'Failed to update account'); + toast.error(errMessage(err, 'Failed to update account')); } finally { setTogglingAccount(null); } }; - function connWarning(conn) { + function connWarning(conn: Connection): { kind: string; label: string } | null { if (!conn.last_error) return null; if (conn.status === 'error') return { kind: 'error', label: 'Sync error' }; // Partial errlist: sync succeeded but some bank connections need attention @@ -729,12 +803,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setExpandedAccount(prev => prev === account.id ? null : account.id)} onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)} toggling={togglingAccount === account.id} - bills={bills} onMatch={tx => handleMatch(conn.id, tx)} onUnmatch={tx => handleUnmatch(conn.id, tx)} matchingTxId={matchingTxId} @@ -863,7 +935,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
    {acc.org_name ? `${acc.org_name} β€” ` : ''}{acc.name} - {acc.balance_dollars !== null && ( + {acc.balance_dollars != null && ( {formatUSD(acc.balance_dollars)} diff --git a/client/components/data/ConnectionHero.jsx b/client/components/data/ConnectionHero.tsx similarity index 80% rename from client/components/data/ConnectionHero.jsx rename to client/components/data/ConnectionHero.tsx index be03f67..5ecb2c1 100644 --- a/client/components/data/ConnectionHero.jsx +++ b/client/components/data/ConnectionHero.tsx @@ -1,12 +1,12 @@ -import { useState } from 'react'; +import { useState, type ComponentType, type ReactNode } from 'react'; import { toast } from 'sonner'; import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; // Relative "2h ago" / "3d ago"; returns null for empty input. -function relativeTime(iso) { +function relativeTime(iso: string | null | undefined): string | null { if (!iso) return null; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return null; @@ -20,19 +20,23 @@ function relativeTime(iso) { return `${days}d ago`; } -function HeroShell({ tone = 'default', icon: Icon, children }) { - const toneRing = { +function HeroShell({ tone = 'default', icon: Icon, children }: { + tone?: string; + icon: ComponentType<{ className?: string }>; + children: ReactNode; +}) { + const toneRing = ({ default: 'border-border/60', good: 'border-emerald-500/30', warn: 'border-amber-500/40', error: 'border-rose-500/30', - }[tone]; - const toneChip = { + } as Record)[tone]; + const toneChip = ({ default: 'bg-muted/50 text-muted-foreground', good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', - }[tone]; + } as Record)[tone]; return (
    @@ -43,6 +47,24 @@ function HeroShell({ tone = 'default', icon: Icon, children }) { ); } +interface ConnData { + name?: string | null; + last_sync_at?: string | null; + last_error?: string | null; +} + +interface ConnectionHeroProps { + loading?: boolean; + error?: unknown; // truthy when the status/summary fetch failed + enabled?: boolean; // status.enabled (server feature flag) + hasConnections?: boolean; // status.has_connections + conn?: ConnData | null; // the simplefin data_source (name, last_sync_at, last_error) or null + txnTotal?: number | null; // total synced transactions (at-a-glance), or null + onRetry?: () => void; + onGoTo?: (sectionId: string) => void; // (sectionId) => void + onSynced?: () => void; // () => void β€” refresh after a successful sync +} + /** * The Data page's connection status hero. Five states, so a network blip is never * mistaken for "not connected", and a server without bank sync never gets a dead @@ -51,21 +73,21 @@ function HeroShell({ tone = 'default', icon: Icon, children }) { */ export default function ConnectionHero({ loading, - error, // truthy when the status/summary fetch failed - enabled, // status.enabled (server feature flag) - hasConnections, // status.has_connections - conn, // the simplefin data_source (name, last_sync_at, last_error) or null - txnTotal, // total synced transactions (at-a-glance), or null + error, + enabled, + hasConnections, + conn, + txnTotal, onRetry, - onGoTo, // (sectionId) => void - onSynced, // () => void β€” refresh after a successful sync -}) { + onGoTo, + onSynced, +}: ConnectionHeroProps) { const [syncing, setSyncing] = useState(false); async function handleSyncNow() { setSyncing(true); try { - const r = await api.syncAllSources(); + const r = await api.syncAllSources() as { errors?: unknown[]; transactions_new?: number }; const errs = Array.isArray(r?.errors) ? r.errors : []; if (errs.length) { toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`); @@ -75,8 +97,8 @@ export default function ConnectionHero({ } onSynced?.(); } catch (err) { - if (err?.status === 429) toast.error('Please wait a moment before syncing again.'); - else toast.error(err?.message || 'Sync failed.'); + if ((err as { status?: number })?.status === 429) toast.error('Please wait a moment before syncing again.'); + else toast.error(errMessage(err, 'Sync failed.')); } finally { setSyncing(false); } diff --git a/client/components/data/DataNav.jsx b/client/components/data/DataNav.tsx similarity index 84% rename from client/components/data/DataNav.jsx rename to client/components/data/DataNav.tsx index 762bf6c..e537e6a 100644 --- a/client/components/data/DataNav.jsx +++ b/client/components/data/DataNav.tsx @@ -1,20 +1,34 @@ +import type { ComponentType } from 'react'; import { cn } from '@/lib/utils'; -const DOT_TONES = { +const DOT_TONES: Record = { green: 'bg-emerald-500', amber: 'bg-amber-500', red: 'bg-rose-500', gray: 'bg-muted-foreground/40', }; +export interface DataNavSection { + id: string; + label: string; + description?: string; + icon?: ComponentType<{ className?: string }>; + dot?: string; + badge?: number | null; +} + +interface DataNavProps { + sections: DataNavSection[]; + active: string; + onSelect: (id: string) => void; +} + /** * Goal-oriented navigation for the Data page. Desktop (β‰₯ lg): a sticky vertical * list. Mobile: a horizontally-scrollable segmented control. One
    ))}
    - {preview.data.warnings?.length > 0 && ( + {(preview.data.warnings?.length ?? 0) > 0 && (
    - {preview.data.warnings.map((warning, i) => ( + {preview.data.warnings?.map((warning, i) => (

    {warning}

    diff --git a/client/components/data/ImportOfxSection.jsx b/client/components/data/ImportOfxSection.tsx similarity index 82% rename from client/components/data/ImportOfxSection.jsx rename to client/components/data/ImportOfxSection.tsx index 99697d7..4c4b3ae 100644 --- a/client/components/data/ImportOfxSection.jsx +++ b/client/components/data/ImportOfxSection.tsx @@ -2,28 +2,44 @@ import { useRef, useState } from 'react'; import { toast } from 'sonner'; import { FileText, Upload, Loader2, CheckCircle2, X } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import { formatCentsUSD } from '@/lib/money'; -import { SectionCard, importErrorState } from './dataShared'; +import { formatCentsUSD, type Cents } from '@/lib/money'; +import { SectionCard, importErrorState, type SectionCardProps } from './dataShared'; + +interface OfxTx { + payee?: string | null; + description?: string | null; + posted_date?: string | null; + amount: Cents; +} + +interface OfxPreview { + import_session_id?: string; + count: number; + sample?: OfxTx[]; +} /** * Import bank transactions from an OFX / QFX file. Unlike CSV, the file is * structured, so there is no column-mapping step: upload β†’ preview β†’ import. * Duplicates are skipped by the server (same dedupe scope as CSV/SimpleFIN). */ -export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }) { - const fileRef = useRef(null); - const [preview, setPreview] = useState(null); // { import_session_id, count, sample } - const [busy, setBusy] = useState(null); // 'preview' | 'commit' | null +export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }: { + onHistoryRefresh?: () => void; + cardProps?: Partial; +}) { + const fileRef = useRef(null); + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState<'preview' | 'commit' | null>(null); - async function handleFile(e) { + async function handleFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setBusy('preview'); setPreview(null); try { - setPreview(await api.previewOfxTransactionImport(file)); + setPreview(await api.previewOfxTransactionImport(file) as OfxPreview); } catch (err) { toast.error(importErrorState(err, 'Could not read that OFX/QFX file.').message); } finally { @@ -36,15 +52,15 @@ export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }) { if (!preview?.import_session_id) return; setBusy('commit'); try { - const r = await api.commitOfxTransactionImport({ import_session_id: preview.import_session_id }); - const parts = [`${r.imported} imported`]; + const r = await api.commitOfxTransactionImport({ import_session_id: preview.import_session_id }) as { imported?: number; skipped?: number; failed?: number }; + const parts = [`${r.imported ?? 0} imported`]; if (r.skipped) parts.push(`${r.skipped} already present`); if (r.failed) parts.push(`${r.failed} failed`); toast.success(`OFX import complete β€” ${parts.join(', ')}.`); setPreview(null); onHistoryRefresh?.(); } catch (err) { - toast.error(err.message || 'OFX import failed.'); + toast.error(errMessage(err, 'OFX import failed.')); } finally { setBusy(null); } diff --git a/client/components/data/ImportSpreadsheetSection.jsx b/client/components/data/ImportSpreadsheetSection.tsx similarity index 80% rename from client/components/data/ImportSpreadsheetSection.jsx rename to client/components/data/ImportSpreadsheetSection.tsx index 57a3d96..c4edd7c 100644 --- a/client/components/data/ImportSpreadsheetSection.jsx +++ b/client/components/data/ImportSpreadsheetSection.tsx @@ -1,33 +1,169 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { toast } from 'sonner'; import { Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck, - Loader2, RefreshCw, ChevronDown, ChevronUp, SkipForward, Plus, - List, Building2, ChevronLeft, FileText, XCircle, Sparkles, + Loader2, ChevronDown, ChevronUp, SkipForward, Plus, + List, Building2, ChevronLeft, XCircle, } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; -import { - AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; -import { SectionCard, CountPill, fmt, importErrorState } from './dataShared'; +import { SectionCard, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared'; +import type { Bill, Category } from '@/types'; -function groupRowsBySheet(rows) { - const map = new Map(); - for (const row of rows) { - const key = row.sheet_name || '(unknown sheet)'; - if (!map.has(key)) map.set(key, []); - map.get(key).push(row); - } - return Array.from(map.entries()).map(([name, rows]) => ({ name, rows })); +interface BillMatch { + bill_id: number; + bill_name?: string; + expected_amount?: number | string; + match_confidence: string; } -function initialDecisionFromRecommendation(row) { +interface Recommendation { + action?: string | null; + bill_id?: number | null; + bill_name?: string | null; + category_id?: number | null; + category_name?: string | null; + due_day?: number | null; + actual_amount?: number | null; + expected_amount?: number | null; + payment_amount?: number | null; + payment_date?: string | null; + confidence?: string; + reason?: string; + warnings?: string[]; +} + +interface PreviewRow { + row_id: number | string; + sheet_name?: string | null; + source_row_number?: number; + proposed_action?: string | null; + requires_user_decision?: boolean; + recommendation?: Recommendation; + possible_bill_matches?: BillMatch[]; + raw_values?: unknown[]; + errors?: unknown[]; + warnings?: string[]; + detected_bill_name?: string | null; + detected_name?: string | null; + detected_amount?: number | null; + detected_payment_amount?: number | null; + detected_paid_date?: string | null; + detected_notes?: string | null; + detected_labels?: string[]; + detected_year?: number | null; + detected_month?: number | null; + year_month_source?: string | null; + _match?: BillMatch; +} + +interface Decision { + action?: string | null; + bill_id?: number | null; + bill_name?: string | null; + previous_match_bill_id?: number | null; + category_id?: number | null; + due_day?: number | null; + expected_amount?: number | null; + actual_amount?: number | null; + payment_amount?: number | null; + payment_date?: string | null; + notes?: string | null; + [key: string]: unknown; +} + +interface WorkbookSheet { + name: string; + status?: string; + row_count?: number; + detected_year?: number | null; + detected_month?: number | null; + is_non_month_sheet?: boolean; +} + +interface Workbook { + parse_mode?: string; + total_row_count?: number; + row_count?: number; + selected_sheet?: string; + sheet_names?: string[]; + sheets?: WorkbookSheet[]; +} + +interface PreviewData { + import_session_id?: string; + rows: PreviewRow[]; + workbook: Workbook; +} + +interface ImportDetail { + row_id?: number | string; + result?: string; + payment?: string; + existing_created_at?: string; + field?: string; + message?: string; +} + +interface ApplyResult { + rows_created?: number; + rows_updated?: number; + rows_skipped?: number; + rows_errored?: number; + rows_duplicates?: number; + details?: ImportDetail[]; +} + +interface BillImportResult { + created?: number; + updated?: number; + errored?: number; + duplicates?: number; + earliestDup?: Date | null; + latestDup?: Date | null; + completedRowIds?: Set; + erroredRowIds?: Set; +} + +interface BillGroup { + bill: Bill; + rows: PreviewRow[]; + counts: { high: number; medium: number; low: number }; +} + +interface PreviewState { + status: 'idle' | 'loading' | 'ready' | 'error'; + data: PreviewData | null; + error: ImportErrorState | null; +} + +interface ApplyState { + status: 'idle' | 'loading' | 'done' | 'error'; + result: ApplyResult | null; + error: ImportErrorState | null; +} + +interface Options { + parseAllSheets: boolean; + defaultYear: number | string; + defaultMonth: number | string; +} + +function groupRowsBySheet(rows: PreviewRow[]): { name: string; rows: PreviewRow[] }[] { + const map = new Map(); + for (const row of rows) { + const key = row.sheet_name || '(unknown sheet)'; + const list = map.get(key); + if (list) list.push(row); + else map.set(key, [row]); + } + return Array.from(map.entries()).map(([name, groupRows]) => ({ name, rows: groupRows })); +} + +function initialDecisionFromRecommendation(row: PreviewRow): Decision { const rec = row.recommendation || {}; const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null); @@ -62,7 +198,7 @@ function initialDecisionFromRecommendation(row) { return { action }; } -function safeRawBillName(row) { +function safeRawBillName(row: PreviewRow): string { const raw = row.raw_values?.find((v) => { const text = String(v || '').trim(); if (!text || text.length > 80) return false; @@ -75,7 +211,7 @@ function safeRawBillName(row) { return raw ? String(raw).trim() : ''; } -function buildCreateNewDecision(row, currentDecision = {}) { +function buildCreateNewDecision(row: PreviewRow, currentDecision: Decision = {}): Decision { const rec = row.recommendation || {}; const billName = currentDecision.bill_name || row.detected_bill_name @@ -98,10 +234,10 @@ function buildCreateNewDecision(row, currentDecision = {}) { }; } -function buildInitialDecisions(rows) { - const d = {}; +function buildInitialDecisions(rows: PreviewRow[]): Record { + const d: Record = {}; for (const row of rows) { - const hasError = row.errors?.length > 0; + const hasError = (row.errors?.length ?? 0) > 0; if (hasError || row.proposed_action === 'skip_row') { d[row.row_id] = { action: 'skip_row' }; } else { @@ -111,7 +247,7 @@ function buildInitialDecisions(rows) { return d; } -function isDecisionComplete(action, decision) { +function isDecisionComplete(action: string | null | undefined, decision: Decision | undefined): boolean { if (!action) return false; if (action === 'skip_row') return true; if (action === 'create_new_bill') return !!(decision?.bill_name?.trim()); @@ -123,28 +259,29 @@ function isDecisionComplete(action, decision) { // ─── Badges ─────────────────────────────────────────────────────────────────── -function SourceBadge({ source }) { - const MAP = { +function SourceBadge({ source }: { source?: string }) { + const MAP: Record = { row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500', ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400', }; - const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; + const LABELS: Record = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; + const key = source ?? ''; return ( - - {LABELS[source] ?? source} + + {LABELS[key] ?? source} ); } -function ConfidenceBadge({ confidence }) { - const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; - return {confidence}; +function ConfidenceBadge({ confidence }: { confidence?: string }) { + const MAP: Record = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; + return {confidence}; } -function actionLabel(action) { - const MAP = { +function actionLabel(action?: string | null): string { + const MAP: Record = { match_existing_bill: 'Match existing bill', create_new_bill: 'Create new bill', skip_row: 'Skip row', @@ -153,27 +290,28 @@ function actionLabel(action) { add_monthly_note: 'Add monthly note', create_payment: 'Record as payment', }; - return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision'); + return MAP[action ?? ''] || (action ? action.replace(/_/g, ' ') : 'Needs decision'); } - -function SheetStatusBadge({ status }) { - const MAP = { +function SheetStatusBadge({ status }: { status?: string }) { + const MAP: Record = { parsed: 'bg-emerald-500/15 text-emerald-600', parsed_month_only: 'bg-amber-500/15 text-amber-600', ambiguous: 'bg-orange-500/15 text-orange-600', skipped: 'bg-muted text-muted-foreground', }; - const LABELS = { + const LABELS: Record = { parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped', }; + const key = status ?? ''; return ( - - {LABELS[status] ?? status} + + {LABELS[key] ?? status} ); } -function WorkbookSummaryCard({ workbook }) { + +function WorkbookSummaryCard({ workbook }: { workbook: Workbook }) { const isMulti = workbook.parse_mode === 'all_sheets'; return ( @@ -181,12 +319,12 @@ function WorkbookSummaryCard({ workbook }) {

    Workbook Summary

    - {isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows Β· ${workbook.selected_sheet}`} + {isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names?.length ?? 0} tabs` : `${workbook.row_count} rows Β· ${workbook.selected_sheet}`}
    - {isMulti && workbook.sheets?.length > 0 && ( + {isMulti && (workbook.sheets?.length ?? 0) > 0 && (
    - {workbook.sheets.map(s => ( + {workbook.sheets?.map(s => (
    void; + allBills: Bill[]; + categories: Category[]; + selected: boolean; + onSelectedChange: (rowId: number | string, selected: boolean) => void; +} + +function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }: RowDecisionRowProps) { const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action); const action = decision?.action ?? null; const isSkip = action === 'skip_row'; - const hasError = row.errors?.length > 0; + const hasError = (row.errors?.length ?? 0) > 0; const complete = isDecisionComplete(action, decision); const rec = row.recommendation || {}; @@ -226,11 +374,11 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, const suggestedIds = new Set(suggestedBills.map(b => b.bill_id)); const otherBills = allBills.filter(b => !suggestedIds.has(b.id)); - const handleAction = (val) => { - const next = { ...decision, action: val }; + const handleAction = (val: string | null) => { + const next: Decision = { ...decision, action: val }; if (val === 'create_new_bill') { Object.assign(next, buildCreateNewDecision(row, decision)); - } else if (ACTIONS_NEEDING_BILL.has(val)) { + } else if (val != null && ACTIONS_NEEDING_BILL.has(val)) { next.bill_name = null; next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null; next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null; @@ -244,15 +392,15 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, if (val === 'skip_row') setExpanded(false); }; - const handleBill = (e) => { + const handleBill = (e: React.ChangeEvent) => { onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null }); }; - const handleBillName = (e) => { + const handleBillName = (e: React.ChangeEvent) => { onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value }); }; - const handleDecisionField = (field, value) => { + const handleDecisionField = (field: string, value: unknown) => { onDecisionChange(row.row_id, { ...decision, [field]: value }); }; @@ -313,8 +461,8 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, paid {row.detected_paid_date} )} - {row.detected_labels?.length > 0 && ( - {row.detected_labels.join(', ')} + {(row.detected_labels?.length ?? 0) > 0 && ( + {row.detected_labels?.join(', ')} )} {row.detected_notes && ( {row.detected_notes} @@ -360,7 +508,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, )} {/* Warnings */} - {(rec.warnings?.length > 0 || row.warnings?.length > 0) && ( + {((rec.warnings?.length ?? 0) > 0 || (row.warnings?.length ?? 0) > 0) && (
    {Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (

    @@ -413,7 +561,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,

    {/* Bill selector (for actions that need a bill) */} - {ACTIONS_NEEDING_BILL.has(action) && ( + {action != null && ACTIONS_NEEDING_BILL.has(action) && (
    ) { return ( = { saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' }, saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' }, error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' }, @@ -12,8 +18,8 @@ const STATES = { * Tiny animated pill that reflects auto-save state. Renders nothing while idle β€” * the page communicates "changes save automatically" once, statically. */ -export function SaveStatus({ status, className }) { - const state = STATES[status]; +export function SaveStatus({ status, className }: { status?: string | null; className?: string }) { + const state = status ? STATES[status] : undefined; return ( {state && ( diff --git a/client/components/ui/select.jsx b/client/components/ui/select.tsx similarity index 84% rename from client/components/ui/select.jsx rename to client/components/ui/select.tsx index 7bc742d..e7c19ac 100644 --- a/client/components/ui/select.jsx +++ b/client/components/ui/select.tsx @@ -1,12 +1,13 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const Select = SelectPrimitive.Root; const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; -function SelectTrigger({ className, children, ref, ...props }) { +function SelectTrigger({ className, children, ref, ...props }: ComponentProps) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return (
    ) { return ( ) { return ( ) { return ( ) { return ( ) { return (
    ) { return ( ) { return (
    ) { return ( ) { return ( ) { return ( ) { return ( & { inset?: boolean }) { return ( t.value === theme) ?? THEMES[1]; + const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]!; const CurrentIcon = current.Icon; return ( diff --git a/client/components/ui/tooltip.jsx b/client/components/ui/tooltip.tsx similarity index 90% rename from client/components/ui/tooltip.jsx rename to client/components/ui/tooltip.tsx index b16c921..bbc1296 100644 --- a/client/components/ui/tooltip.jsx +++ b/client/components/ui/tooltip.tsx @@ -1,11 +1,12 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const TooltipProvider = TooltipPrimitive.Provider; const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; -function TooltipContent({ className, sideOffset = 4, ref, ...props }) { +function TooltipContent({ className, sideOffset = 4, ref, ...props }: ComponentProps) { return ( void; +} -export function ThemeProvider({ children }) { - const [theme, setThemeState] = useState(() => { +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { const stored = loadStoredTheme(); // Apply immediately (before first paint) to avoid flash applyTheme(stored); return stored; }); - const setTheme = (newTheme) => { - if (!VALID_THEMES.includes(newTheme)) return; - applyTheme(newTheme); - setThemeState(newTheme); + const setTheme = (newTheme: string) => { + if (!(VALID_THEMES as string[]).includes(newTheme)) return; + applyTheme(newTheme as Theme); + setThemeState(newTheme as Theme); try { localStorage.setItem(STORAGE_KEY, newTheme); } catch { diff --git a/client/hooks/useAuth.jsx b/client/hooks/useAuth.jsx deleted file mode 100644 index e2fd33d..0000000 --- a/client/hooks/useAuth.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react'; -import { api } from '@/api'; - -const AuthContext = createContext(null); - -export function AuthProvider({ children }) { - const [user, setUser] = useState(undefined); // undefined = loading - const [singleUserMode, setSUM] = useState(false); - const [hasNewVersion, setHasNewVersion] = useState(false); - - function applyMeResponse(d) { - setUser(d.user); - setSUM(d.single_user_mode || false); - setHasNewVersion(!!d.has_new_version); - } - - useEffect(() => { - api.authMode().then(d => { - if (d.auth_mode === 'single') setSUM(true); - }).catch(err => console.error('[useAuth] authMode check failed', err)); - - api.me().then(applyMeResponse).catch(() => setUser(null)); - }, []); - - const logout = async () => { - await api.logout(); - setUser(null); - setSUM(false); - setHasNewVersion(false); - }; - - const refresh = () => { - api.me().then(applyMeResponse).catch(() => { - setUser(null); - setSUM(false); - }); - }; - - return ( - - {children} - - ); -} - -export function useAuth() { - return useContext(AuthContext) || { - user: null, setUser: () => {}, logout: () => {}, refresh: () => {}, - singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {}, - }; -} diff --git a/client/hooks/useAuth.tsx b/client/hooks/useAuth.tsx new file mode 100644 index 0000000..587abdf --- /dev/null +++ b/client/hooks/useAuth.tsx @@ -0,0 +1,82 @@ +import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react'; +import { api } from '@/api'; + +export interface User { + id: number; + username?: string; + role?: string; + display_name?: string; + displayName?: string; + name?: string; + is_default_admin?: boolean; + [key: string]: unknown; +} + +interface MeResponse { + user?: User | null; + single_user_mode?: boolean; + has_new_version?: boolean; +} + +interface AuthContextValue { + user: User | null | undefined; // undefined = loading + singleUserMode: boolean; + setUser: Dispatch>; + logout: () => void | Promise; + refresh: () => void; + hasNewVersion: boolean; + setHasNewVersion: (v: boolean) => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(undefined); // undefined = loading + const [singleUserMode, setSUM] = useState(false); + const [hasNewVersion, setHasNewVersion] = useState(false); + + function applyMeResponse(raw: unknown) { + const d = raw as MeResponse; + setUser(d.user); + setSUM(d.single_user_mode || false); + setHasNewVersion(!!d.has_new_version); + } + + useEffect(() => { + api.authMode().then(d => { + if ((d as { auth_mode?: string }).auth_mode === 'single') setSUM(true); + }).catch(err => console.error('[useAuth] authMode check failed', err)); + + api.me().then(applyMeResponse).catch(() => setUser(null)); + }, []); + + const logout = async () => { + await api.logout(); + setUser(null); + setSUM(false); + setHasNewVersion(false); + }; + + const refresh = () => { + api.me().then(applyMeResponse).catch(() => { + setUser(null); + setSUM(false); + }); + }; + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + return useContext(AuthContext) || { + user: null, setUser: () => {}, logout: () => {}, refresh: () => {}, + singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {}, + }; +} diff --git a/client/hooks/useAutoSave.test.jsx b/client/hooks/useAutoSave.test.ts similarity index 91% rename from client/hooks/useAutoSave.test.jsx rename to client/hooks/useAutoSave.test.ts index f6ff066..32b8253 100644 --- a/client/hooks/useAutoSave.test.jsx +++ b/client/hooks/useAutoSave.test.ts @@ -8,7 +8,7 @@ describe('useAutoSave', () => { afterEach(() => vi.useRealTimers()); it('debounces: only the latest payload is saved, once', async () => { - const save = vi.fn().mockResolvedValue(); + const save = vi.fn().mockResolvedValue(undefined); const { result } = renderHook(() => useAutoSave(save, 400)); act(() => { @@ -25,7 +25,7 @@ describe('useAutoSave', () => { }); it('per-call delay overrides the default', async () => { - const save = vi.fn().mockResolvedValue(); + const save = vi.fn().mockResolvedValue(undefined); const { result } = renderHook(() => useAutoSave(save, 400)); act(() => { result.current.schedule({ slow: true }, 900); }); @@ -36,7 +36,7 @@ describe('useAutoSave', () => { }); it('flush() saves a pending payload immediately and is a no-op when idle', async () => { - const save = vi.fn().mockResolvedValue(); + const save = vi.fn().mockResolvedValue(undefined); const { result } = renderHook(() => useAutoSave(save, 400)); await act(async () => { result.current.flush(); }); @@ -55,7 +55,7 @@ describe('useAutoSave', () => { it('reports error status when the save rejects, then recovers on next save', async () => { const save = vi.fn() .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce(); + .mockResolvedValueOnce(undefined); const { result } = renderHook(() => useAutoSave(save, 100)); act(() => { result.current.schedule({ x: 1 }); }); @@ -68,7 +68,7 @@ describe('useAutoSave', () => { }); it('"saved" fades back to idle after 2 seconds', async () => { - const save = vi.fn().mockResolvedValue(); + const save = vi.fn().mockResolvedValue(undefined); const { result } = renderHook(() => useAutoSave(save, 100)); act(() => { result.current.schedule({ y: 1 }); }); @@ -80,7 +80,7 @@ describe('useAutoSave', () => { }); it('flushes a pending payload on unmount so edits are never lost', async () => { - const save = vi.fn().mockResolvedValue(); + const save = vi.fn().mockResolvedValue(undefined); const { result, unmount } = renderHook(() => useAutoSave(save, 900)); act(() => { result.current.schedule({ unsaved: true }); }); diff --git a/client/hooks/useAutoSave.js b/client/hooks/useAutoSave.ts similarity index 70% rename from client/hooks/useAutoSave.js rename to client/hooks/useAutoSave.ts index 8e944b8..dc4b2d1 100644 --- a/client/hooks/useAutoSave.js +++ b/client/hooks/useAutoSave.ts @@ -1,5 +1,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +type SaveFn = (payload: T) => unknown; + +export interface UseAutoSave { + status: AutoSaveStatus; + schedule: (payload: T, delay?: number) => void; + flush: () => void; +} + /** * Debounced auto-save with a status the UI can render. * @@ -10,14 +20,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; * * Pending changes are flushed on unmount so navigating away never loses edits. */ -export function useAutoSave(saveFn, defaultDelay = 400) { - const [status, setStatus] = useState('idle'); - const timer = useRef(null); - const pending = useRef(null); - const saveRef = useRef(saveFn); +export function useAutoSave(saveFn: SaveFn, defaultDelay = 400): UseAutoSave { + const [status, setStatus] = useState('idle'); + const timer = useRef | undefined>(undefined); + const pending = useRef(null); + const saveRef = useRef>(saveFn); saveRef.current = saveFn; - const run = useCallback(async (payload) => { + const run = useCallback(async (payload: T) => { pending.current = null; setStatus('saving'); try { @@ -28,7 +38,7 @@ export function useAutoSave(saveFn, defaultDelay = 400) { } }, []); - const schedule = useCallback((payload, delay = defaultDelay) => { + const schedule = useCallback((payload: T, delay = defaultDelay) => { pending.current = payload; clearTimeout(timer.current); timer.current = setTimeout(() => run(payload), delay); diff --git a/client/hooks/usePaymentActions.js b/client/hooks/usePaymentActions.js deleted file mode 100644 index fa8f617..0000000 --- a/client/hooks/usePaymentActions.js +++ /dev/null @@ -1,42 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { api } from '@/api.js'; -import { fmt } from '@/lib/utils'; -import { useInvalidateTrackerData } from '@/hooks/useQueries'; - -// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a -// reversible Undo toast. A React Query mutation: `isPending` comes for free and -// the tracker/badge caches are invalidated on settle. Shared by the desktop and -// mobile tracker rows so the flow lives in one place. -export function useQuickPay() { - const invalidate = useInvalidateTrackerData(); - const mutation = useMutation({ - mutationFn: (payload) => api.quickPay(payload), - onSettled: () => invalidate(), - }); - - const run = (row, val, paidDate) => mutation.mutate( - { bill_id: row.id, amount: val, paid_date: paidDate }, - { - onSuccess: (payment) => { - toast.success(`${row.name} β€” ${fmt(val)} paid`, { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.deletePayment(payment.id); - toast.success('Payment removed'); - invalidate(); - } catch (err) { - toast.error(err.message || 'Failed to undo payment.'); - } - }, - }, - }); - }, - onError: (err) => toast.error(err.message || 'Failed to add payment.'), - }, - ); - - return { run, isPending: mutation.isPending }; -} diff --git a/client/hooks/usePaymentActions.ts b/client/hooks/usePaymentActions.ts new file mode 100644 index 0000000..bf0e5e0 --- /dev/null +++ b/client/hooks/usePaymentActions.ts @@ -0,0 +1,105 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { fmt, errMessage } from '@/lib/utils'; +import type { Dollars } from '@/lib/money'; +import { useInvalidateTrackerData } from '@/hooks/useQueries'; + +/** The bits of a tracker row these payment actions need. */ +interface PayableRow { + id: number; + name: string; +} + +// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a +// reversible Undo toast. A React Query mutation: `isPending` comes for free and +// the tracker/badge caches are invalidated on settle. Shared by the desktop and +// mobile tracker rows so the flow lives in one place. +export function useQuickPay() { + const invalidate = useInvalidateTrackerData(); + const mutation = useMutation({ + mutationFn: (payload: { bill_id: number; amount: number; paid_date: string }) => api.quickPay(payload), + onSettled: () => invalidate(), + }); + + const run = (row: PayableRow, val: Dollars, paidDate: string) => mutation.mutate( + { bill_id: row.id, amount: val, paid_date: paidDate }, + { + onSuccess: (payment) => { + toast.success(`${row.name} β€” ${fmt(val)} paid`, { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.deletePayment(payment.id); + toast.success('Payment removed'); + invalidate(); + } catch (err) { + toast.error(errMessage(err, 'Failed to undo payment.')); + } + }, + }, + }); + }, + onError: (err) => toast.error(errMessage(err, 'Failed to add payment.')), + }, + ); + + return { run, isPending: mutation.isPending }; +} + +interface TogglePaidOptions { + wasPaid: boolean; + threshold: Dollars; + setOptimisticPaid: (value: boolean | undefined) => void; +} + +// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip +// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls +// it back on error, invalidates the tracker/badge caches on settle, and shows the +// right toast (an Undo when un-paying, a specific "paid" message otherwise). +// Shared by the desktop and mobile tracker rows. +export function useTogglePaid(year: number, month: number) { + const invalidate = useInvalidateTrackerData(); + const mutation = useMutation({ + mutationFn: ({ rowId, amount }: { rowId: number; amount: number | undefined }) => + api.togglePaid(rowId, { amount, year, month }), + onSettled: () => invalidate(), + }); + + const run = (row: PayableRow, { wasPaid, threshold, setOptimisticPaid }: TogglePaidOptions) => { + setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles + mutation.mutate( + { rowId: row.id, amount: wasPaid ? undefined : threshold }, + { + onSuccess: (result) => { + if (wasPaid && result.paymentId) { + const paymentId = result.paymentId; // capture the narrowed id for the deferred Undo closure + toast.success('Payment moved to recovery', { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.restorePayment(paymentId); + toast.success('Payment restored'); + invalidate(); + } catch (err) { + toast.error(errMessage(err, 'Failed to restore payment')); + } + }, + }, + }); + } else if (!wasPaid) { + toast.success(`${row.name} β€” ${fmt(threshold)} paid`); + } + }, + onError: (err) => { + setOptimisticPaid(undefined); // roll back the instant flip + toast.error(errMessage(err, 'Failed to toggle payment status')); + }, + }, + ); + }; + + return { run, isPending: mutation.isPending }; +} diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.ts similarity index 84% rename from client/hooks/useQueries.js rename to client/hooks/useQueries.ts index 64313e9..ee71991 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.ts @@ -1,14 +1,14 @@ import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useCallback } from 'react'; -import { api } from '@/api'; +import { api, type QueryParams } from '@/api'; // Custom hook for fetching tracker data -export function useTracker(year, month) { +export function useTracker(year: number, month: number) { return useQuery({ queryKey: ['tracker', year, month], queryFn: () => api.tracker(year, month), staleTime: 1000 * 60 * 5, // 5 minutes - cacheTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 30, // 30 minutes }); } @@ -18,7 +18,7 @@ export function useBills() { queryKey: ['bills'], queryFn: () => api.allBills(), staleTime: 1000 * 60 * 5, // 5 minutes - cacheTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 30, // 30 minutes }); } @@ -28,7 +28,7 @@ export function useCategories() { queryKey: ['categories'], queryFn: () => api.categories(), staleTime: 1000 * 60 * 60, // 1 hour - cacheTime: 1000 * 60 * 60 * 2, // 2 hours + gcTime: 1000 * 60 * 60 * 2, // 2 hours }); } @@ -72,7 +72,7 @@ export function useInvalidateTrackerData() { // to it is instant. No-op if it's already cached and fresh. export function usePrefetchTracker() { const queryClient = useQueryClient(); - return useCallback((year, month) => { + return useCallback((year: number, month: number) => { queryClient.prefetchQuery({ queryKey: ['tracker', year, month], queryFn: () => api.tracker(year, month), @@ -86,7 +86,7 @@ export function usePrefetchTracker() { // dedup, cancellation, and out-of-order responses β€” no manual sequence guards. // keepPreviousData keeps the last result visible while a new month/filter loads. -export function useAnalyticsSummary(params) { +export function useAnalyticsSummary(params?: QueryParams) { return useQuery({ queryKey: ['analytics-summary', params], queryFn: () => api.analyticsSummary(params), @@ -111,7 +111,7 @@ export function useDeletedBills() { }); } -export function useSummary(year, month) { +export function useSummary(year: number, month: number) { return useQuery({ queryKey: ['summary', year, month], queryFn: () => api.summary(year, month), @@ -123,7 +123,17 @@ export function useSummary(year, month) { }); } -export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) { +interface BankLedgerArgs { + accountId: string; + flow: string; + page: number; + query: string; + sortBy: string; + sortDir: string; + pageSize: number; +} + +export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }: BankLedgerArgs) { return useQuery({ queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], queryFn: () => api.bankTransactionsLedger({ @@ -140,7 +150,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p }); } -export function useSpendingSummary(year, month) { +export function useSpendingSummary(year: number, month: number) { return useQuery({ queryKey: ['spending-summary', year, month], queryFn: () => api.spendingSummary({ year, month }), @@ -149,11 +159,18 @@ export function useSpendingSummary(year, month) { }); } -export function useSpendingTransactions({ year, month, activeCat, page }) { +interface SpendingTransactionsArgs { + year: number; + month: number; + activeCat?: number | string | null; + page: number; +} + +export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) { return useQuery({ queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page], queryFn: () => { - const params = { year, month, page, limit: 50 }; + const params: QueryParams = { year, month, page, limit: 50 }; if (activeCat === null) params.category_id = 'null'; else if (activeCat !== undefined) params.category_id = activeCat; return api.spendingTransactions(params); @@ -167,8 +184,8 @@ export function useSpendingCategories() { return useQuery({ queryKey: ['spending-categories'], queryFn: async () => { - const d = await api.categories(); - return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled); + const cats = await api.categories(); + return cats.filter(c => !c.deleted_at && c.spending_enabled); }, staleTime: 1000 * 60 * 5, }); @@ -201,7 +218,7 @@ export function useSnowballActivePlan() { export function useSnowballPlans() { return useQuery({ queryKey: ['snowball-plans'], - queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []), + queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []), staleTime: 1000 * 60 * 2, }); } @@ -217,7 +234,7 @@ export function useSubscriptions() { export function useSubscriptionRecommendations() { return useQuery({ queryKey: ['subscription-recommendations'], - queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []), + queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []), staleTime: 1000 * 60 * 5, }); } diff --git a/client/hooks/useSearchPanelPreference.js b/client/hooks/useSearchPanelPreference.ts similarity index 79% rename from client/hooks/useSearchPanelPreference.js rename to client/hooks/useSearchPanelPreference.ts index 6438841..dbbc0fd 100644 --- a/client/hooks/useSearchPanelPreference.js +++ b/client/hooks/useSearchPanelPreference.ts @@ -3,11 +3,11 @@ import { api } from '@/api'; const SETTING_KEY = 'search_bars_collapsed'; -function settingToBool(value) { +function settingToBool(value: unknown): boolean { return value === true || value === 'true' || value === '1' || value === 1; } -export function useSearchPanelPreference() { +export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] { const [collapsed, setCollapsed] = useState(false); useEffect(() => { @@ -23,7 +23,7 @@ export function useSearchPanelPreference() { return () => { mounted = false; }; }, []); - const saveCollapsed = useCallback((nextCollapsed) => { + const saveCollapsed = useCallback((nextCollapsed: boolean) => { setCollapsed(nextCollapsed); api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {}); }, []); diff --git a/client/lib/billDrafts.js b/client/lib/billDrafts.js deleted file mode 100644 index b34e1d3..0000000 --- a/client/lib/billDrafts.js +++ /dev/null @@ -1,42 +0,0 @@ -import { billingCycleForSchedule, scheduleValue } from './billingSchedule'; - -function categoryForTemplate(template, categories = []) { - const keywords = template?.categoryKeywords || []; - const match = categories.find(category => { - const name = String(category.name || '').toLowerCase(); - return keywords.some(keyword => name.includes(keyword)); - }); - return match?.id ?? null; -} - -function categoryIdOrFallback(categoryId, template, categories = []) { - if (categoryId && categories.some(category => String(category.id) === String(categoryId))) { - return categoryId; - } - return template ? categoryForTemplate(template, categories) : null; -} - -export function makeBillDraft(source, { copy = false, template = null, categories = [] } = {}) { - const data = source || {}; - return { - ...data, - id: undefined, - source_bill_id: copy && data.id != null ? data.id : undefined, - active: 1, - name: copy - ? `${data.name || 'Bill'} (Copy)` - : (data.name || template?.name || ''), - category_id: categoryIdOrFallback(data.category_id, template, categories), - due_day: data.due_day || 1, - expected_amount: data.expected_amount ?? 0, - billing_cycle: billingCycleForSchedule(scheduleValue(data)), - cycle_type: scheduleValue(data), - cycle_day: String(data.cycle_day || '1'), - autopay_enabled: !!data.autopay_enabled, - autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'), - auto_mark_paid: !!data.auto_mark_paid, - has_2fa: !!data.has_2fa, - snowball_include: !!data.snowball_include, - snowball_exempt: !!data.snowball_exempt, - }; -} diff --git a/client/lib/billDrafts.ts b/client/lib/billDrafts.ts new file mode 100644 index 0000000..5781f74 --- /dev/null +++ b/client/lib/billDrafts.ts @@ -0,0 +1,85 @@ +import { billingCycleForSchedule, scheduleValue } from './billingSchedule'; + +interface Category { + id: string | number; + name?: string | null; +} + +interface Template { + name?: string | null; + categoryKeywords?: string[]; +} + +// The source bill a draft is seeded from. The specific fields the draft reads +// are declared; the index signature carries every other field through the spread. +export interface SourceBill { + id?: string | number | null; + name?: string | null; + category_id?: string | number | null; + due_day?: number | null; + expected_amount?: number | null; + cycle_type?: string | null; + billing_cycle?: string | null; + cycle_day?: string | number | null; + autopay_enabled?: unknown; + autodraft_status?: string | null; + auto_mark_paid?: unknown; + has_2fa?: unknown; + snowball_include?: unknown; + snowball_exempt?: unknown; + [key: string]: unknown; +} + +interface MakeBillDraftOptions { + copy?: boolean; + template?: Template | null; + categories?: Category[]; +} + +function categoryForTemplate(template: Template | null | undefined, categories: Category[] = []): string | number | null { + const keywords = template?.categoryKeywords || []; + const match = categories.find(category => { + const name = String(category.name || '').toLowerCase(); + return keywords.some(keyword => name.includes(keyword)); + }); + return match?.id ?? null; +} + +function categoryIdOrFallback( + categoryId: string | number | null | undefined, + template: Template | null, + categories: Category[] = [], +): string | number | null { + if (categoryId && categories.some(category => String(category.id) === String(categoryId))) { + return categoryId; + } + return template ? categoryForTemplate(template, categories) : null; +} + +export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}): SourceBill { + const data: SourceBill = source || {}; + return { + ...data, + id: undefined, + source_bill_id: copy && data.id != null ? data.id : undefined, + active: 1, + name: copy + ? `${data.name || 'Bill'} (Copy)` + : (data.name || template?.name || ''), + category_id: categoryIdOrFallback(data.category_id, template, categories), + due_day: data.due_day || 1, + expected_amount: data.expected_amount ?? 0, + billing_cycle: billingCycleForSchedule(scheduleValue(data)), + cycle_type: scheduleValue(data), + cycle_day: String(data.cycle_day || '1'), + autopay_enabled: !!data.autopay_enabled, + autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'), + auto_mark_paid: !!data.auto_mark_paid, + has_2fa: !!data.has_2fa, + snowball_include: !!data.snowball_include, + snowball_exempt: !!data.snowball_exempt, + }; +} + +/** The pre-filled draft object produced by makeBillDraft (fed to BillModal's `initialBill`). */ +export type BillDraft = ReturnType; diff --git a/client/lib/billingSchedule.js b/client/lib/billingSchedule.ts similarity index 60% rename from client/lib/billingSchedule.js rename to client/lib/billingSchedule.ts index ddfd052..7696897 100644 --- a/client/lib/billingSchedule.js +++ b/client/lib/billingSchedule.ts @@ -1,4 +1,6 @@ -export const BILLING_SCHEDULE_OPTIONS = [ +export type Schedule = 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual'; + +export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [ ['monthly', 'Monthly'], ['weekly', 'Weekly'], ['biweekly', 'Biweekly'], @@ -6,22 +8,27 @@ export const BILLING_SCHEDULE_OPTIONS = [ ['annual', 'Annual'], ]; -const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS); +const LABELS: Record = Object.fromEntries(BILLING_SCHEDULE_OPTIONS); -export function scheduleFromBillingCycle(billingCycle) { +export interface ScheduleBill { + cycle_type?: string | null; + billing_cycle?: string | null; +} + +export function scheduleFromBillingCycle(billingCycle: string | null | undefined): Schedule { const value = String(billingCycle || '').toLowerCase(); if (value === 'quarterly') return 'quarterly'; if (value === 'annually' || value === 'annual') return 'annual'; return 'monthly'; } -export function normalizeSchedule(value, fallback = 'monthly') { +export function normalizeSchedule(value: string | null | undefined, fallback = 'monthly'): string { if (!value) return fallback; const normalized = String(value).toLowerCase(); return LABELS[normalized] ? normalized : fallback; } -export function scheduleValue(bill = {}) { +export function scheduleValue(bill: ScheduleBill = {}): string { const cycleType = normalizeSchedule(bill.cycle_type, ''); const billingCycle = String(bill.billing_cycle || '').toLowerCase(); @@ -32,14 +39,14 @@ export function scheduleValue(bill = {}) { return cycleType || scheduleFromBillingCycle(billingCycle); } -export function scheduleLabel(valueOrBill) { +export function scheduleLabel(valueOrBill: string | ScheduleBill | null | undefined): string { const value = valueOrBill && typeof valueOrBill === 'object' ? scheduleValue(valueOrBill) : normalizeSchedule(valueOrBill); return LABELS[value] || 'Monthly'; } -export function billingCycleForSchedule(schedule) { +export function billingCycleForSchedule(schedule: string | null | undefined): string { const value = normalizeSchedule(schedule); if (value === 'quarterly') return 'quarterly'; if (value === 'annual') return 'annually'; @@ -47,7 +54,7 @@ export function billingCycleForSchedule(schedule) { return 'monthly'; } -export function defaultCycleDayForSchedule(schedule) { +export function defaultCycleDayForSchedule(schedule: string | null | undefined): string { const value = normalizeSchedule(schedule); return value === 'weekly' || value === 'biweekly' ? 'monday' : '1'; } diff --git a/client/lib/cashflowUtils.js b/client/lib/cashflowUtils.ts similarity index 55% rename from client/lib/cashflowUtils.js rename to client/lib/cashflowUtils.ts index 1b51a96..4a99a78 100644 --- a/client/lib/cashflowUtils.js +++ b/client/lib/cashflowUtils.ts @@ -1,5 +1,30 @@ // Pure helpers for the Safe-to-Spend card. No DOM, no React β€” unit-testable. +export interface TimelineEntry { + date: string; + balance: number | string | null; + bills?: unknown[]; + payday?: unknown; +} + +export interface GeometryPoint { + x: number; + y: number; + date: string; + balance: number; + bills: unknown[]; + isDrop: boolean; + isPayday: boolean; + isLast: boolean; +} + +export interface TimelineGeometry { + line: string; + area: string; + points: GeometryPoint[]; + zeroY: number; +} + /** * Convert the server's cashflow timeline into SVG step-path geometry. * Returns { line, area, points, zeroY } or null when there is nothing to draw. @@ -9,24 +34,32 @@ * - Y maps balances; the domain always includes 0 so the zero line is honest. * - Step shape: balance holds flat until a bill's day, then drops. */ -export function buildTimelineGeometry(timeline, width, height, pad = 4) { +export function buildTimelineGeometry( + timeline: TimelineEntry[] | null | undefined, + width: number, + height: number, + pad = 4, +): TimelineGeometry | null { if (!Array.isArray(timeline) || timeline.length < 2) return null; - const t0 = Date.parse(timeline[0].date); - const t1 = Date.parse(timeline[timeline.length - 1].date); + // length >= 2 (guarded), so first/last exist β€” the `!` satisfies noUncheckedIndexedAccess. + const first = timeline[0]!; + const last = timeline[timeline.length - 1]!; + const t0 = Date.parse(first.date); + const t1 = Date.parse(last.date); const span = Math.max(t1 - t0, 1); const balances = timeline.map(t => Number(t.balance) || 0); - const start = Number(timeline[0].balance) || 0; + const start = Number(first.balance) || 0; // Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)] const lo = Math.min(0, ...balances); const hi = Math.max(start, ...balances, 1); const range = Math.max(hi - lo, 1); - const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); - const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); + const x = (date: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); + const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); - const points = timeline.map((t, i) => ({ + const points: GeometryPoint[] = timeline.map((t, i) => ({ x: x(t.date), y: y(Number(t.balance) || 0), date: t.date, @@ -38,19 +71,19 @@ export function buildTimelineGeometry(timeline, width, height, pad = 4) { })); // Step path: horizontal to each next x, then vertical drop. - let line = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + let line = `M ${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`; for (let i = 1; i < points.length; i++) { - line += ` H ${points[i].x.toFixed(2)} V ${points[i].y.toFixed(2)}`; + line += ` H ${points[i]!.x.toFixed(2)} V ${points[i]!.y.toFixed(2)}`; } const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0); - const area = `${line} V ${baseY.toFixed(2)} H ${points[0].x.toFixed(2)} Z`; + const area = `${line} V ${baseY.toFixed(2)} H ${points[0]!.x.toFixed(2)} Z`; return { line, area, points, zeroY: y(0) }; } /** "5 days" / "tomorrow" / "today" */ -export function daysUntilLabel(days) { +export function daysUntilLabel(days: number | string | null | undefined): string { const n = Number(days); if (!Number.isFinite(n) || n <= 0) return 'today'; if (n === 1) return 'tomorrow'; @@ -58,15 +91,15 @@ export function daysUntilLabel(days) { } /** "Jul 1" from "2026-07-01" β€” no Date parsing, no timezone traps. */ -export function shortDate(dateStr) { +export function shortDate(dateStr: string | null | undefined): string { if (!dateStr || typeof dateStr !== 'string') return ''; - const [, m, d] = dateStr.split('-'); + const [, m = '', d = ''] = dateStr.split('-'); const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`; } /** Split upcoming bills into the visible few plus an overflow count. */ -export function splitUpcoming(upcoming, maxVisible = 4) { +export function splitUpcoming(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } { const list = Array.isArray(upcoming) ? upcoming : []; return { visible: list.slice(0, maxVisible), diff --git a/client/lib/money.js b/client/lib/money.js deleted file mode 100644 index 7f824e3..0000000 --- a/client/lib/money.js +++ /dev/null @@ -1,77 +0,0 @@ -// Currency formatting for the client, mirroring the server's utils/money.js. -// -// The API sends money in two units: bill / summary values are serialized as -// DOLLARS (the server calls fromCents before responding), while raw bank -// transaction amounts arrive as integer CENTS. So there are two entry points β€” -// formatUSD(dollars) and formatCentsUSD(cents) β€” matching the server's -// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only, -// and this matches the server). Inputs are coerced defensively so null, '', -// undefined, or NaN never render as "$NaN". - -const DASH = 'β€”'; - -function toNumber(value) { - const n = Number(value); - if (!Number.isFinite(n)) return 0; - return n === 0 ? 0 : n; // normalize -0 β†’ +0 so it never renders as "-$0.00" -} - -function isBlank(value) { - return value === null || value === undefined || value === ''; -} - -/** - * Format a DOLLAR amount β†’ "$1,234.56". - * @param {number|string|null} dollars - * @param {{ whole?: boolean, dash?: boolean }} [opts] - * whole β€” drop the cents ("$1,235"); dash β€” render blank input as "β€”" not "$0.00". - */ -export function formatUSD(dollars, { whole = false, dash = false } = {}) { - if (dash && isBlank(dollars)) return DASH; - return toNumber(dollars).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: whole ? 0 : 2, - maximumFractionDigits: whole ? 0 : 2, - }); -} - -/** Whole-dollar convenience β†’ "$1,235". */ -export function formatUSDWhole(dollars, opts = {}) { - return formatUSD(dollars, { ...opts, whole: true }); -} - -/** - * Format an integer-CENTS amount (e.g. a bank transaction) β†’ "$12.34". - * @param {number|string|null} cents - * @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts] - * signed β€” prefix "+"/"-" (income vs expense); dash β€” blank input β†’ "β€”"; - * currency β€” ISO code (defaults USD). - */ -export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) { - if (dash && isBlank(cents)) return DASH; - const c = toNumber(cents); - const body = (Math.abs(c) / 100).toLocaleString('en-US', { - style: 'currency', - currency: currency || 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - if (signed) return (c < 0 ? '-' : '+') + body; - return (c < 0 ? '-' : '') + body; -} - -/** - * Shared form validator for a non-negative money field (dollars). Blank is - * allowed (returns ''); otherwise the value must parse to a number β‰₯ 0. Returns - * '' when valid, or an error string labelled for the field. Zero is allowed β€” - * these are non-negative, not strictly-positive, amounts. - * @param {number|string|null} val - * @param {string} [label] β€” field name for the message (e.g. "Amount", "Balance") - */ -export function validateNonNegativeMoney(val, label = 'Amount') { - if (val === '' || val === null || val === undefined) return ''; - const num = parseFloat(val); - if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; - return ''; -} diff --git a/client/lib/money.ts b/client/lib/money.ts new file mode 100644 index 0000000..14344b3 --- /dev/null +++ b/client/lib/money.ts @@ -0,0 +1,108 @@ +// Currency formatting for the client, mirroring the server's utils/money.js. +// +// The API sends money in two units: bill / summary values are serialized as +// DOLLARS (the server calls fromCents before responding), while raw bank +// transaction amounts arrive as integer CENTS. So there are two entry points β€” +// formatUSD(dollars) and formatCentsUSD(cents) β€” matching the server's +// formatUSD / formatCentsUSD split. USD / en-US throughout. Inputs are coerced +// defensively so null, '', undefined, or NaN never render as "$NaN". +// +// The two units are *branded* below: a Dollars value can't be passed where Cents +// is expected (or vice-versa) without an explicit conversion, so the cents↔dollars +// mixups that have caused real money bugs (e.g. displaying cents as dollars β†’ +// 100Γ— wrong) become compile errors in typed code. + +/** Integer cents, e.g. a raw bank transaction amount (1234 = $12.34). */ +export type Cents = number & { readonly __unit: 'cents' }; +/** Dollars, e.g. an API-serialized bill amount (12.34 = $12.34). */ +export type Dollars = number & { readonly __unit: 'dollars' }; + +/** Brand a raw number as cents (the sanctioned way to enter the typed world). */ +export const asCents = (n: number): Cents => n as Cents; +/** Brand a raw number as dollars. */ +export const asDollars = (n: number): Dollars => n as Dollars; +/** Convert cents β†’ dollars (the only way to cross the unit boundary). */ +export const centsToDollars = (c: Cents): Dollars => (c / 100) as Dollars; +/** Convert dollars β†’ cents, rounding to the nearest cent. */ +export const dollarsToCents = (d: Dollars): Cents => Math.round(d * 100) as Cents; + +// A money value as it may still arrive untyped from a form field or legacy code: +// the branded unit, or a numeric string, or blank. Note bare `number` is NOT +// included β€” that's deliberate, so typed callers must brand (asDollars/asCents) +// and can't accidentally hand cents to a dollars formatter. +type DollarsInput = Dollars | string | null | undefined; +type CentsInput = Cents | string | null | undefined; + +const DASH = 'β€”'; + +function toNumber(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return n === 0 ? 0 : n; // normalize -0 β†’ +0 so it never renders as "-$0.00" +} + +function isBlank(value: unknown): value is null | undefined | '' { + return value === null || value === undefined || value === ''; +} + +/** + * Format a DOLLAR amount β†’ "$1,234.56". + * `whole` drops the cents ("$1,235"); `dash` renders blank input as "β€”". + */ +export function formatUSD( + dollars: DollarsInput, + { whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {}, +): string { + if (dash && isBlank(dollars)) return DASH; + return toNumber(dollars).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: whole ? 0 : 2, + maximumFractionDigits: whole ? 0 : 2, + }); +} + +/** Whole-dollar convenience β†’ "$1,235". */ +export function formatUSDWhole( + dollars: DollarsInput, + opts: { dash?: boolean } = {}, +): string { + return formatUSD(dollars, { ...opts, whole: true }); +} + +/** + * Format an integer-CENTS amount (e.g. a bank transaction) β†’ "$12.34". + * `signed` prefixes "+"/"-" (income vs expense); `dash` renders blank β†’ "β€”"; + * `currency` is an ISO code (defaults USD). + */ +export function formatCentsUSD( + cents: CentsInput, + { signed = false, dash = false, currency = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {}, +): string { + if (dash && isBlank(cents)) return DASH; + const c = toNumber(cents); + const body = (Math.abs(c) / 100).toLocaleString('en-US', { + style: 'currency', + currency: currency || 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + if (signed) return (c < 0 ? '-' : '+') + body; + return (c < 0 ? '-' : '') + body; +} + +/** + * Shared form validator for a non-negative money field (dollars). Blank is + * allowed (returns ''); otherwise the value must parse to a number β‰₯ 0. Returns + * '' when valid, or an error string labelled for the field. Zero is allowed β€” + * these are non-negative, not strictly-positive, amounts. + */ +export function validateNonNegativeMoney( + val: string | number | null | undefined, + label = 'Amount', +): string { + if (val === '' || val === null || val === undefined) return ''; + const num = parseFloat(String(val)); + if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; + return ''; +} diff --git a/client/lib/money.type-test.ts b/client/lib/money.type-test.ts new file mode 100644 index 0000000..943b919 --- /dev/null +++ b/client/lib/money.type-test.ts @@ -0,0 +1,22 @@ +// Compile-time guard for the branded money types. Not imported anywhere (so it +// never ships in a bundle); `npm run typecheck` type-checks it via the tsconfig +// include, and each `@ts-expect-error` asserts the following line IS a genuine +// type error. If the cents/dollars branding ever regresses, one of these lines +// stops erroring and typecheck fails loudly ("unused @ts-expect-error"). +import { formatUSD, formatCentsUSD, asCents, asDollars, centsToDollars } from './money'; + +const cents = asCents(1234); // $12.34 as integer cents +const dollars = asDollars(12.34); // $12.34 as dollars + +// βœ… Correct unit β†’ compiles fine. +formatUSD(dollars); +formatCentsUSD(cents); +formatUSD(centsToDollars(cents)); // cross the boundary explicitly + +// ❌ Unit mixups β†’ compile errors (the class of bug we keep fixing). +// @ts-expect-error cents can't be formatted as dollars (would render 100Γ— too big) +formatUSD(cents); +// @ts-expect-error dollars can't be formatted as cents +formatCentsUSD(dollars); +// @ts-expect-error a raw number must be branded (asDollars/asCents) first +formatUSD(1234); diff --git a/client/lib/reorder.js b/client/lib/reorder.ts similarity index 50% rename from client/lib/reorder.js rename to client/lib/reorder.ts index 65034d6..622a17e 100644 --- a/client/lib/reorder.js +++ b/client/lib/reorder.ts @@ -1,19 +1,27 @@ -export function moveInArray(items, fromIndex, toIndex) { +interface Identified { + id: string | number; +} + +export function moveInArray(items: T[], fromIndex: number, toIndex: number): T[] { if (!Array.isArray(items)) return []; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) { return items; } const next = [...items]; const [moved] = next.splice(fromIndex, 1); + if (moved === undefined) return next; // in-range by the guard above; satisfies noUncheckedIndexedAccess next.splice(toIndex, 0, moved); return next; } -export function reorderPayload(items) { +export function reorderPayload(items: ReadonlyArray | null | undefined): Record { return Object.fromEntries((items || []).map((item, index) => [item.id, index])); } -export function movedItemId(before, after) { +export function movedItemId( + before: ReadonlyArray | null | undefined, + after: ReadonlyArray | null | undefined, +): string | number | null { const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id); return moved?.id || after?.[0]?.id || null; } diff --git a/client/lib/trackerTableColumns.js b/client/lib/trackerTableColumns.ts similarity index 80% rename from client/lib/trackerTableColumns.js rename to client/lib/trackerTableColumns.ts index 7ea29dd..6ebc08a 100644 --- a/client/lib/trackerTableColumns.js +++ b/client/lib/trackerTableColumns.ts @@ -12,19 +12,19 @@ export const TRACKER_TABLE_COLUMNS = [ export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key); export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS]; -export function parseTrackerTableColumns(value) { +export function parseTrackerTableColumns(value: unknown): string[] { if (Array.isArray(value)) return normalizeTrackerTableColumns(value); if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS; try { - const parsed = JSON.parse(value); + const parsed = JSON.parse(value as string); return normalizeTrackerTableColumns(parsed); } catch { return DEFAULT_TRACKER_TABLE_COLUMNS; } } -export function normalizeTrackerTableColumns(columns) { +export function normalizeTrackerTableColumns(columns: unknown): string[] { const valid = new Set(TRACKER_TABLE_COLUMN_KEYS); return Array.isArray(columns) ? columns.filter(column => valid.has(column)) @@ -32,6 +32,6 @@ export function normalizeTrackerTableColumns(columns) { : DEFAULT_TRACKER_TABLE_COLUMNS; } -export function trackerTableColumnsToSetting(columns) { +export function trackerTableColumnsToSetting(columns: unknown): string { return JSON.stringify(normalizeTrackerTableColumns(columns)); } diff --git a/client/lib/trackerUtils.js b/client/lib/trackerUtils.ts similarity index 79% rename from client/lib/trackerUtils.js rename to client/lib/trackerUtils.ts index a76cf48..8035196 100644 --- a/client/lib/trackerUtils.js +++ b/client/lib/trackerUtils.ts @@ -1,4 +1,10 @@ import { todayStr } from '@/lib/utils'; +import { asDollars, type Dollars } from '@/lib/money'; +import type { TrackerRow, TrackerStatus } from '@/types'; + +// Re-export the shared domain types so existing `@/lib/trackerUtils` importers +// keep resolving them (the canonical definitions live in `@/types`). +export type { TrackerRow, TrackerStatus } from '@/types'; export const MONTHS = [ 'January','February','March','April','May','June', @@ -12,6 +18,8 @@ export const TRACKER_SORT_DEFAULT = 'manual'; export const TRACKER_SORT_ASC = 'asc'; export const TRACKER_SORT_DESC = 'desc'; +export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_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 }, @@ -32,7 +40,7 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries( TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir]) ); -export const ROW_STATUS_CLS = { +export const ROW_STATUS_CLS: Record = { 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: '', @@ -51,7 +59,11 @@ export const STATUS_META = { skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; -export function paymentDateForTrackerMonth(year, month, dueDay) { +export function paymentDateForTrackerMonth( + year: number, + month: number, + dueDay: number | string | null | undefined, +): string { const now = new Date(); if (year === now.getFullYear() && month === now.getMonth() + 1) { return todayStr(); @@ -65,7 +77,7 @@ export function paymentDateForTrackerMonth(year, month, dueDay) { return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } -export function amountSearchText(...values) { +export function amountSearchText(...values: unknown[]): string { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { @@ -75,11 +87,11 @@ export function amountSearchText(...values) { .join(' '); } -export function rowThreshold(row) { +export function rowThreshold(row: TrackerRow): Dollars { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } -export function rowEffectiveStatus(row) { +export function rowEffectiveStatus(row: TrackerRow): TrackerStatus { if (row.is_skipped) return 'skipped'; const threshold = rowThreshold(row); const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; @@ -89,24 +101,24 @@ export function rowEffectiveStatus(row) { // A bill's month is "settled" when its status is paid or autodraft. Mirrors the // server's statusService.isPaidStatus β€” the single low-level check the various // row/badge/calendar components share. -export function isPaidStatus(status) { +export function isPaidStatus(status: string): boolean { return status === 'paid' || status === 'autodraft'; } -export function rowIsPaid(row) { +export function rowIsPaid(row: TrackerRow): boolean { const status = rowEffectiveStatus(row); if (row.autopay_suggestion && status === 'autodraft') return false; return isPaidStatus(status); } -export function rowIsDebt(row) { +export function rowIsDebt(row: TrackerRow): boolean { 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 = { +const STATUS_SORT_ORDER: Record = { missed: 0, late: 1, due_soon: 2, @@ -116,18 +128,20 @@ const STATUS_SORT_ORDER = { skipped: 6, }; -function parseDateSortValue(value) { +function parseDateSortValue(value: string | null | undefined): number | null { if (!value) return null; const parsed = Date.parse(`${value}T00:00:00`); return Number.isFinite(parsed) ? parsed : null; } -function numericSortValue(value) { +function numericSortValue(value: unknown): number { const number = Number(value); return Number.isFinite(number) ? number : 0; } -function trackerSortValue(row, key) { +type SortValue = string | number | null | undefined; + +function trackerSortValue(row: TrackerRow, key: string): SortValue { switch (key) { case 'name': return String(row.name || '').toLowerCase(); @@ -150,7 +164,7 @@ function trackerSortValue(row, key) { } } -function compareSortValues(a, b, dir) { +function compareSortValues(a: SortValue, b: SortValue, dir: string): number { const aMissing = a === null || a === undefined || a === ''; const bMissing = b === null || b === undefined || b === ''; if (aMissing && bMissing) return 0; @@ -166,15 +180,15 @@ function compareSortValues(a, b, dir) { return dir === TRACKER_SORT_DESC ? -result : result; } -export function normalizeTrackerSortKey(key) { +export function normalizeTrackerSortKey(key: string): string { return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT; } -export function normalizeTrackerSortDir(dir) { +export function normalizeTrackerSortDir(dir: string): TrackerSortDir { return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC; } -export function sortTrackerRows(rows, sortKey, sortDir) { +export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] { const key = normalizeTrackerSortKey(sortKey); if (key === TRACKER_SORT_DEFAULT) return rows; @@ -200,14 +214,15 @@ export function sortTrackerRows(rows, sortKey, sortDir) { .map(item => item.row); } -export function moveInArray(items, fromIndex, toIndex) { +export function moveInArray(items: T[], fromIndex: number, toIndex: number): T[] { const next = [...items]; const [moved] = next.splice(fromIndex, 1); + if (moved === undefined) return next; next.splice(toIndex, 0, moved); return next; } -export function paymentSummary(row, threshold) { +export function paymentSummary(row: TrackerRow, threshold: Dollars) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) @@ -218,12 +233,14 @@ export function paymentSummary(row, threshold) { : 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; + // Re-brand the computed amounts as dollars (arithmetic on branded numbers + // yields a plain number) so callers can pass them straight to fmt/formatUSD. return { - target, - paid, - paidTowardDue, - overpaid, - remaining, + target: asDollars(target), + paid: asDollars(paid), + paidTowardDue: asDollars(paidTowardDue), + overpaid: asDollars(overpaid), + remaining: asDollars(remaining), percent, count: Array.isArray(row.payments) ? row.payments.length : 0, partial: paid > 0 && remaining > 0, diff --git a/client/lib/utils.js b/client/lib/utils.ts similarity index 67% rename from client/lib/utils.js rename to client/lib/utils.ts index e0ea3a2..00461b2 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.ts @@ -1,36 +1,43 @@ -import { clsx } from 'clsx'; +import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { formatUSD } from './money'; -export function cn(...inputs) { +export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many // existing call sites; the implementation lives in ./money (formatUSD) so all -// currency formatting has a single source of truth. -export function fmt(amount) { +// currency formatting has a single source of truth. Inherits formatUSD's typed +// (branded-dollars) input. +export function fmt(amount: Parameters[0]): string { return formatUSD(amount); } -export function fmtDate(dateStr) { +// Narrow an unknown error (strict catch clauses give `unknown`) to a message +// string, falling back when the thrown value isn't an Error with a message. +export function errMessage(err: unknown, fallback = 'Something went wrong'): string { + return err instanceof Error && err.message ? err.message : fallback; +} + +export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return 'β€”'; - const [y, m, d] = dateStr.split('-'); + const [y = '', m = '', d = ''] = dateStr.split('-'); return `${parseInt(m)}/${parseInt(d)}/${y}`; } -export function localDateString(date = new Date()) { +export function localDateString(date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } -export function todayStr() { +export function todayStr(): string { return localDateString(); } -export function fmtUptime(seconds) { +export function fmtUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -41,14 +48,21 @@ export function fmtUptime(seconds) { return `${s}s`; } -export function fmtBytes(bytes) { +export function fmtBytes(bytes: number | null | undefined): string { if (!bytes) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(2)} MB`; } -const CATEGORY_COLOR_TONES = [ +interface CategoryTone { + border: string; + bg: string; + text: string; + bar: string; +} + +const CATEGORY_COLOR_TONES: CategoryTone[] = [ { border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500' }, { border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500' }, { border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' }, @@ -61,12 +75,12 @@ const CATEGORY_COLOR_TONES = [ // Deterministic color tone for a category (or merchant) name, used for // badges and avatars so the same name always renders the same color. -export function categoryColor(name) { +export function categoryColor(name: string | null | undefined): CategoryTone { const key = String(name || 'Uncategorized'); let hash = 0; for (let i = 0; i < key.length; i++) { hash = (hash * 31 + key.charCodeAt(i)) | 0; } const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length; - return CATEGORY_COLOR_TONES[index]; + return CATEGORY_COLOR_TONES[index]!; // index is always in range } diff --git a/client/lib/version.js b/client/lib/version.ts similarity index 86% rename from client/lib/version.js rename to client/lib/version.ts index 182f6b2..720e454 100644 --- a/client/lib/version.js +++ b/client/lib/version.ts @@ -1,10 +1,24 @@ // __APP_VERSION__ is injected by Vite at build time from package.json. // Do not hardcode a version string here β€” update package.json instead. -/* global __APP_VERSION__ */ +declare const __APP_VERSION__: string; + export const APP_VERSION = __APP_VERSION__; export const APP_NAME = 'BillTracker'; -export const RELEASE_NOTES = { +export interface ReleaseHighlight { + icon: string; + title: string; + desc: string; +} + +export interface ReleaseNotes { + version: string; + date: string; + highlights: ReleaseHighlight[]; + image: { src: string; alt: string }; +} + +export const RELEASE_NOTES: ReleaseNotes = { version: APP_VERSION, date: '2026-05-31', highlights: [ diff --git a/client/main.jsx b/client/main.tsx similarity index 90% rename from client/main.jsx rename to client/main.tsx index 5b65e64..11d7325 100644 --- a/client/main.jsx +++ b/client/main.tsx @@ -8,7 +8,7 @@ import { ThemeProvider } from './contexts/ThemeContext'; import 'sonner/dist/styles.css'; import './index.css'; -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/pages/AboutPage.jsx b/client/pages/AboutPage.tsx similarity index 89% rename from client/pages/AboutPage.jsx rename to client/pages/AboutPage.tsx index 1a4c2b9..53b8691 100644 --- a/client/pages/AboutPage.jsx +++ b/client/pages/AboutPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { ArrowLeft, ArrowUpCircle, CheckCircle2, Info, Loader2, Sparkles, AlertCircle } from 'lucide-react'; import { api } from '@/api'; @@ -9,7 +9,22 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com const REPOSITORY_URL = 'https://dream.scheller.ltd/null/BillTracker'; -function UpdateBadge({ status, loading }) { +interface UpdateStatus { + has_update?: boolean; + latest_release_url?: string | null; + latest_version?: string; + up_to_date?: boolean | null; + error?: string | null; +} + +interface AboutInfo { + version?: string; + name?: string; + description?: string; + stack?: { backend?: string; database?: string }; +} + +function UpdateBadge({ status, loading }: { status: UpdateStatus | null; loading: boolean }) { if (loading) { return ( @@ -55,15 +70,15 @@ function UpdateBadge({ status, loading }) { export default function AboutPage() { const { user } = useAuth(); - const [about, setAbout] = useState(null); + const [about, setAbout] = useState(null); const [loading, setLoading] = useState(true); - const [updateStatus, setUpdateStatus] = useState(null); + const [updateStatus, setUpdateStatus] = useState(null); const [updateLoading, setUpdateLoading] = useState(true); const load = useCallback(async () => { setLoading(true); try { - setAbout(await api.about()); + setAbout(await api.about() as AboutInfo); } finally { setLoading(false); } @@ -74,7 +89,7 @@ export default function AboutPage() { useEffect(() => { setUpdateLoading(true); api.updateStatus() - .then(setUpdateStatus) + .then(d => setUpdateStatus(d as UpdateStatus)) .catch(() => setUpdateStatus(null)) .finally(() => setUpdateLoading(false)); }, []); diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.tsx similarity index 76% rename from client/pages/AdminPage.jsx rename to client/pages/AdminPage.tsx index 130cb17..5580813 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import AppNavigation from '@/components/layout/Sidebar'; import OnboardingWizard from '@/components/admin/OnboardingWizard'; import EmailNotifCard from '@/components/admin/EmailNotifCard'; @@ -11,20 +12,22 @@ import UsersTable from '@/components/admin/UsersTable'; import AddUserCard from '@/components/admin/AddUserCard'; import BackupManagementCard from '@/components/admin/BackupManagementCard'; import CleanupPanel from '@/components/admin/CleanupPanel'; +import type { AdminUser } from '@/components/admin/UsersTable'; +import type { User } from '@/hooks/useAuth'; export default function AdminPage() { const navigate = useNavigate(); - const [me, setMe] = useState(null); - const [hasUsers, setHasUsers] = useState(null); + const [me, setMe] = useState(null); + const [hasUsers, setHasUsers] = useState(null); const [loadError, setLoadError] = useState(''); - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); const [authMode, setAuthMode] = useState('multi'); const loadMe = useCallback(async () => { try { - const d = await api.me(); - setMe(d.user); + const d = await api.me() as { user?: User }; + setMe(d.user ?? null); } catch { navigate('/login', { replace: true }); } @@ -32,18 +35,18 @@ export default function AdminPage() { const loadUsers = useCallback(async () => { try { - const d = await api.adminUsers(); - setUsers(d.users || d); - } catch {} + const d = await api.adminUsers() as { users?: AdminUser[] } | AdminUser[]; + setUsers(Array.isArray(d) ? d : (d.users || [])); + } catch { /* ignore */ } }, []); const loadHasUsers = useCallback(async () => { try { - const d = await api.hasUsers(); - setHasUsers(d.has_users); + const d = await api.hasUsers() as { has_users?: boolean }; + setHasUsers(!!d.has_users); if (d.has_users) loadUsers(); } catch (err) { - setLoadError(err.message || 'Failed to load admin page'); + setLoadError(errMessage(err, 'Failed to load admin page')); setHasUsers(false); } }, [loadUsers]); diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.tsx similarity index 88% rename from client/pages/AnalyticsPage.jsx rename to client/pages/AnalyticsPage.tsx index 211dff9..bbdb2cb 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.tsx @@ -1,18 +1,46 @@ -import React, { useMemo, useState } from 'react'; -import { formatUSD, formatUSDWhole } from '@/lib/money'; +import { useMemo, useState, type ReactNode } from 'react'; +import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { useAnalyticsSummary } from '@/hooks/useQueries'; import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/Skeleton'; import { cn } from '@/lib/utils'; +interface TrendRow { month: string; label?: string; total: number } +interface ExpectedActualRow { month: string; label?: string; expected: number; actual: number } +interface CategorySpendRow { category_name: string; total: number } +interface HeatmapCell { month: string; status: string; amount_paid?: number } +interface HeatmapMonth { key: string; label: string } +interface HeatmapRow { bill_id: number; bill_name: string; category_name?: string; cells: HeatmapCell[] } +interface HeatmapData { rows?: HeatmapRow[]; months?: HeatmapMonth[] } +interface ForecastRow { month: string; label: string; total: number; low: number; high: number } +interface AnalyticsRange { start?: string; end?: string } +interface AnalyticsData { + monthly_spending?: TrendRow[]; + expected_vs_actual?: ExpectedActualRow[]; + category_spend?: CategorySpendRow[]; + heatmap?: HeatmapData; + categories?: { id: number | string; name: string }[]; + bills?: { id: number | string; name: string; active?: number }[]; + range?: AnalyticsRange; + generated_at?: string; +} + +interface ChartVisibility { + monthlyTrend: boolean; + expectedActual: boolean; + categorySpend: boolean; + heatmap: boolean; + forecast: boolean; + [key: string]: boolean; +} + const RANGE_OPTIONS = [6, 12, 24, 36]; -const MONTH_OPTIONS = [ +const MONTH_OPTIONS: [string, string][] = [ ['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'], ['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'], ['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'], ]; -const CHART_OPTIONS = [ +const CHART_OPTIONS: [string, string][] = [ ['monthlyTrend', 'Monthly trend'], ['expectedActual', 'Expected vs actual'], ['categorySpend', 'Category spend'], @@ -26,24 +54,24 @@ function currentMonth() { return { year: now.getFullYear(), month: now.getMonth() + 1 }; } -function money(value) { - return formatUSDWhole(value); +function money(value: number | null | undefined): string { + return formatUSDWhole(asDollars(Number(value) || 0)); } -function fullMoney(value) { - return formatUSD(value); +function fullMoney(value: number | null | undefined): string { + return formatUSD(asDollars(Number(value) || 0)); } -function formatRange(range) { +function formatRange(range?: AnalyticsRange): string { if (!range?.start || !range?.end) return 'Selected range'; return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`; } -function hasData(rows, keys) { - return rows?.some(row => keys.some(key => Number(row[key]) > 0)); +function hasData(rows: readonly unknown[] | undefined, keys: string[]): boolean { + return !!rows?.some(row => keys.some(key => Number((row as Record)[key]) > 0)); } -function EmptyState({ label = 'No analytics data for this selection.' }) { +function EmptyState({ label = 'No analytics data for this selection.' }: { label?: string }) { return (
    {label} @@ -51,7 +79,12 @@ function EmptyState({ label = 'No analytics data for this selection.' }) { ); } -function ChartCard({ title, subtitle, children, summary }) { +function ChartCard({ title, subtitle, children, summary }: { + title: ReactNode; + subtitle?: ReactNode; + children: ReactNode; + summary?: ReactNode; +}) { return (
    @@ -66,7 +99,7 @@ function ChartCard({ title, subtitle, children, summary }) { ); } -function SvgFrame({ children, height = 260, label = 'Chart' }) { +function SvgFrame({ children, height = 260, label = 'Chart' }: { children: ReactNode; height?: number; label?: string }) { return (
    @@ -76,7 +109,7 @@ function SvgFrame({ children, height = 260, label = 'Chart' }) { ); } -function LineChart({ rows, area = false }) { +function LineChart({ rows, area = false }: { rows: TrendRow[]; area?: boolean }) { if (!hasData(rows, ['total'])) return ; const width = 720; @@ -121,7 +154,7 @@ function LineChart({ rows, area = false }) { ); } -function GroupedBarChart({ rows }) { +function GroupedBarChart({ rows }: { rows: ExpectedActualRow[] }) { if (!hasData(rows, ['expected', 'actual'])) return ; const width = 720; @@ -172,7 +205,7 @@ function GroupedBarChart({ rows }) { ); } -function DonutChart({ rows }) { +function DonutChart({ rows }: { rows: CategorySpendRow[] }) { const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0); if (!total) return ; @@ -226,14 +259,14 @@ function DonutChart({ rows }) { ); } -const HEATMAP_CLASS = { +const HEATMAP_CLASS: Record = { paid: 'bg-emerald-500/85 border-emerald-400/40', skipped: 'bg-sky-500/70 border-sky-400/40', missed: 'bg-red-500/75 border-red-400/40', no_data: 'bg-muted border-border', }; -function Heatmap({ heatmap }) { +function Heatmap({ heatmap }: { heatmap?: HeatmapData }) { const rows = heatmap?.rows || []; const months = heatmap?.months || []; if (!rows.length || !months.length) return ; @@ -260,7 +293,7 @@ function Heatmap({ heatmap }) {

    {row.category_name}

    {months.map(month => { - const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 }; + const cell: HeatmapCell = row.cells.find(item => item.month === month.key) || { month: month.key, status: 'no_data', amount_paid: 0 }; return (
    - {[ + {([ ['paid', 'Paid'], ['skipped', 'Skipped'], ['missed', 'Missed'], ['no_data', 'No data'], - ].map(([status, label]) => ( + ] as [string, string][]).map(([status, label]) => ( {label} @@ -293,7 +326,7 @@ function Heatmap({ heatmap }) { // ─── Forecast ──────────────────────────────────────────────────────────────── -function linearForecast(rows, horizonMonths) { +function linearForecast(rows: TrendRow[], horizonMonths: number): ForecastRow[] { if (rows.length < 3) return []; const n = rows.length; @@ -309,8 +342,10 @@ function linearForecast(rows, horizonMonths) { const residuals = ys.map((y, i) => y - (slope * i + intercept)); const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n); - const lastRow = rows[n - 1]; - const [lastYear, lastMonthNum] = lastRow.month.split('-').map(Number); + const lastRow = rows[n - 1]!; + const parts = lastRow.month.split('-').map(Number); + const lastYear = parts[0] ?? 0; + const lastMonthNum = parts[1] ?? 1; return Array.from({ length: horizonMonths }, (_, i) => { const x = n + i; @@ -328,7 +363,7 @@ function linearForecast(rows, horizonMonths) { }); } -function ForecastChart({ historical, forecast }) { +function ForecastChart({ historical, forecast }: { historical: TrendRow[]; forecast: ForecastRow[] }) { const allRows = [...historical, ...forecast]; if (!allRows.length) return ; @@ -340,7 +375,7 @@ function ForecastChart({ historical, forecast }) { const maxVal = Math.max(...historical.map(r => r.total), ...forecast.map(r => r.high), 1); - function toXY(index, value) { + function toXY(index: number, value: number) { const x = pad.left + (allRows.length === 1 ? chartW / 2 : (index / (allRows.length - 1)) * chartW); const y = pad.top + chartH - (value / maxVal) * chartH; return { x, y }; @@ -355,11 +390,12 @@ function ForecastChart({ historical, forecast }) { })); const histLine = histPoints.map(p => `${p.x},${p.y}`).join(' '); + const lastHist = histPoints[histPoints.length - 1]; - const dividerX = histPoints.length ? histPoints[histPoints.length - 1].x : null; + const dividerX = lastHist ? lastHist.x : null; - const foreLine = forePoints.length && histPoints.length - ? `${histPoints[histPoints.length - 1].x},${histPoints[histPoints.length - 1].y} ` + + const foreLine = forePoints.length && lastHist + ? `${lastHist.x},${lastHist.y} ` + forePoints.map(p => `${p.x},${p.y}`).join(' ') : ''; @@ -367,7 +403,7 @@ function ForecastChart({ historical, forecast }) { ? [...forePoints.map(p => `${p.x},${p.yHigh}`), ...forePoints.slice().reverse().map(p => `${p.x},${p.yLow}`)].join(' ') : ''; - const showLabel = (index) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0; + const showLabel = (index: number) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0; return ( @@ -396,7 +432,7 @@ function ForecastChart({ historical, forecast }) { {/* Historical line + area fill */} @@ -458,7 +494,7 @@ function ForecastChart({ historical, forecast }) { ); } -function Field({ label, children }) { +function Field({ label, children }: { label: ReactNode; children: ReactNode }) { return (
    - @@ -1060,24 +1071,14 @@ export default function BankTransactionsPage() { : `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}

    - {page} / {totalPages} - diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.tsx similarity index 87% rename from client/pages/BillsPage.jsx rename to client/pages/BillsPage.tsx index 9f865a0..67d6165 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useBills, useCategories, useBillTemplates, useDeletedBills } from '@/hooks/useQueries'; @@ -20,14 +20,19 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import BillsTableInner from '@/components/BillsTableInner'; import BillModal from '@/components/BillModal'; import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog'; -import { makeBillDraft } from '@/lib/billDrafts'; +import { makeBillDraft, type SourceBill } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; +import type { Bill } from '@/types'; +import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; + +interface BuiltInTemplate { id: string; name: string; data: SourceBill; categoryKeywords: string[] } +interface BillTemplate { id: number | string; name: string; data?: SourceBill; categoryKeywords?: string[] } const VISIBILITY_OPTIONS = [ { value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' }, @@ -35,13 +40,13 @@ const VISIBILITY_OPTIONS = [ { 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 = [ +const MONTH_OPTIONS: [string, string][] = [ ['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'], ['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'], ]; const FILTER_ALL = 'all'; -const BUILT_IN_TEMPLATES = [ +const BUILT_IN_TEMPLATES: BuiltInTemplate[] = [ { id: 'builtin-utility', name: 'Utility', @@ -122,7 +127,26 @@ const BUILT_IN_TEMPLATES = [ // BillModal is imported from @/components/BillModal -function blankRange() { +interface HistoryRange { + id?: number; + _key: string; + start_year: string; + start_month: string; + end_year: string; + end_month: string; + label: string; +} +interface RawRange { + id?: number; + _key?: string; + start_year?: number | string | null; + start_month?: number | string | null; + end_year?: number | string | null; + end_month?: number | string | null; + label?: string | null; +} + +function blankRange(): HistoryRange { const year = String(new Date().getFullYear()); return { _key: `new-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -134,7 +158,7 @@ function blankRange() { }; } -function normalizeRange(range) { +function normalizeRange(range: RawRange): HistoryRange { return { ...range, _key: range._key || String(range.id), @@ -146,7 +170,7 @@ function normalizeRange(range) { }; } -function rangePayload(range) { +function rangePayload(range: HistoryRange) { return { start_year: parseInt(range.start_year, 10), start_month: parseInt(range.start_month, 10), @@ -156,7 +180,7 @@ function rangePayload(range) { }; } -function validateRange(range) { +function validateRange(range: HistoryRange): string | null { 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.'; @@ -167,7 +191,7 @@ function validateRange(range) { 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 != null && payload.end_month != null) { if (payload.end_year < 2000 || payload.end_year > 2100) { return 'End year must be between 2000 and 2100.'; } @@ -185,7 +209,20 @@ function validateRange(range) { const PREFS_KEY = 'bills-display-prefs-v1'; -const PREFS_DEFAULTS = { +interface DisplayPrefs { + showCategory: boolean; + showDueDay: boolean; + showAmount: boolean; + showCycle: boolean; + showApr: boolean; + showBalance: boolean; + showMinPayment: boolean; + showAutopay: boolean; + show2fa: boolean; + showSubscription: boolean; +} + +const PREFS_DEFAULTS: DisplayPrefs = { showCategory: true, showDueDay: true, showAmount: true, @@ -198,7 +235,7 @@ const PREFS_DEFAULTS = { showSubscription: true, }; -const PREFS_LABELS = [ +const PREFS_LABELS: [keyof DisplayPrefs, string][] = [ ['showCategory', 'Category'], ['showDueDay', 'Due day'], ['showAmount', 'Amount'], @@ -212,7 +249,7 @@ const PREFS_LABELS = [ ]; function useDisplayPrefs() { - const [prefs, setPrefs] = useState(() => { + const [prefs, setPrefs] = useState(() => { try { const raw = localStorage.getItem(PREFS_KEY); return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS; @@ -221,10 +258,10 @@ function useDisplayPrefs() { } }); - const toggle = (key) => { + const toggle = (key: keyof DisplayPrefs) => { setPrefs(prev => { const next = { ...prev, [key]: !prev[key] }; - try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch {} + try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch { /* ignore quota errors */ } return next; }); }; @@ -232,14 +269,14 @@ function useDisplayPrefs() { return { prefs, toggle }; } -function DisplayPrefsPanel({ prefs, onToggle }) { +function DisplayPrefsPanel({ prefs, onToggle }: { prefs: DisplayPrefs; onToggle: (key: keyof DisplayPrefs) => void }) { const [open, setOpen] = useState(false); - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { if (!open) return; - const handler = (e) => { - if (ref.current && !ref.current.contains(e.target)) setOpen(false); + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); @@ -290,7 +327,7 @@ function DisplayPrefsPanel({ prefs, onToggle }) { ); } -function amountSearchText(...values) { +function amountSearchText(...values: (number | string | null | undefined)[]): string { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { @@ -300,14 +337,14 @@ function amountSearchText(...values) { .join(' '); } -function billIsDebt(bill) { +function billIsDebt(bill: Bill): boolean { 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 }) { +function FilterChip({ active, children, onClick }: { active: boolean; children: React.ReactNode; onClick: () => void }) { return (
    @@ -418,14 +560,14 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou />

    - {fmt(starting)} starting Β· {fmt(billsTotal)} due + {fmt(starting)} starting Β· {fmt(billsTotalN)} due

    ); } -function CashFlowCard({ cashflow, year, month }) { +function CashFlowCard({ cashflow, year, month }: { cashflow?: Cashflow; year: number; month: number }) { if (!cashflow?.has_data) return null; const periodProjected = Number(cashflow.period_projected ?? 0); @@ -503,7 +645,7 @@ function CashFlowCard({ cashflow, year, month }) { ); } -function DebtPayoffGlance({ projection }) { +function DebtPayoffGlance({ projection }: { projection: SnowballProjection | null }) { const snowball = projection?.snowball; const comparison = projection?.comparison; const targetDebt = snowball?.debts?.[0] || null; @@ -626,7 +768,12 @@ function DebtPayoffGlance({ projection }) { ); } -function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) { +function DayDetailDialog({ day, open, onOpenChange, moneyMarker }: { + day: CalDay | null; + open: boolean; + onOpenChange: (open: boolean) => void; + moneyMarker: MoneyMarker | null; +}) { return ( @@ -730,7 +877,7 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) { ); } -function CalendarSubscribeDialog({ open, onOpenChange }) { +function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { return ( @@ -758,12 +905,12 @@ export default function CalendarPage() { const initial = currentMonth(); const [year, setYear] = useState(initial.year); const [month, setMonth] = useState(initial.month); - const [data, setData] = useState(null); - const [summaryData, setSummaryData] = useState(null); - const [snowballProjection, setSnowballProjection] = useState(null); + const [data, setData] = useState(null); + const [summaryData, setSummaryData] = useState(null); + const [snowballProjection, setSnowballProjection] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [selectedDay, setSelectedDay] = useState(null); + const [selectedDay, setSelectedDay] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [calendarFeedOpen, setCalendarFeedOpen] = useState(false); @@ -781,14 +928,14 @@ export default function CalendarPage() { throw calendarResult.reason; } - const result = calendarResult.value; + const result = calendarResult.value as CalendarData; setData(result); - setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null); - setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null); + setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value as SummaryDataShape : null); + setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value as SnowballProjection : null); setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null); } catch (err) { - setError(err.message || 'Calendar data could not be loaded.'); - toast.error(err.message || 'Calendar data could not be loaded.'); + setError(errMessage(err, 'Calendar data could not be loaded.')); + toast.error(errMessage(err, 'Calendar data could not be loaded.')); } finally { setLoading(false); } @@ -798,11 +945,11 @@ export default function CalendarPage() { const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]); const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0; - const moneyMarkers = useMemo(() => { + const moneyMarkers = useMemo>(() => { const starting = summaryData?.starting_amounts; if (!starting) return {}; - const markers = {}; + const markers: Record = {}; const firstAmount = Number(starting.first_amount || 0); const fifteenthAmount = Number(starting.fifteenth_amount || 0); @@ -823,7 +970,7 @@ export default function CalendarPage() { }, [month, summaryData, year]); const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null; - function navigate(delta) { + function navigate(delta: number) { const next = shiftMonth(year, month, delta); setYear(next.year); setMonth(next.month); diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.tsx similarity index 87% rename from client/pages/CategoriesPage.jsx rename to client/pages/CategoriesPage.tsx index 9ef761b..832d702 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart, } from 'lucide-react'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InputDialog } from '@/components/ui/input-dialog'; @@ -15,30 +15,83 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { cn, fmt, fmtDate } from '@/lib/utils'; +import { cn, fmtDate, errMessage } from '@/lib/utils'; +import { formatUSD, asDollars } from '@/lib/money'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; -function plural(count, label) { +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); +} + +interface CategoryBill { + id: number; + name: string; + due_day?: number | null; + expected_amount?: number; + total_paid?: number; + payment_count?: number; + last_paid_date?: string | null; + active?: number; +} + +interface CatRow { + id: number; + name: string; + active_bill_count?: number; + inactive_bill_count?: number; + payment_count?: number; + bill_names?: string[]; + bills?: CategoryBill[]; + spending_enabled?: number | boolean; + [key: string]: unknown; +} + +interface CategoryMoveControls { + enabled: boolean; + moving: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: (event: React.MouseEvent) => void; + onMoveDown: (event: React.MouseEvent) => void; +} + +interface CategoryDragProps { + draggable: boolean; + isDragging?: boolean; + isDropTarget?: boolean; + onDragStart?: React.DragEventHandler; + onDragEnter?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnd?: React.DragEventHandler; + onDrop?: React.DragEventHandler; +} + +function plural(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}`; } -function billPreview(names = []) { +function billPreview(names: string[] = []): string { if (!names.length) return 'No bills in this category yet.'; const visible = names.slice(0, 4).join(', '); const more = names.length > 4 ? `, +${names.length - 4} more` : ''; return `${visible}${more}`; } -function categoryBillCount(category) { +function categoryBillCount(category: CatRow): number { return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); } -function Chip({ value, label, tone = 'muted', details }) { - const toneClass = { +function Chip({ value, label, tone = 'muted', details }: { + value: React.ReactNode; + label: string; + tone?: string; + details?: string; +}) { + const toneClass = ({ active: 'border-primary/25 bg-primary/10 text-primary', muted: 'border-border bg-muted/55 text-muted-foreground', info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', - }[tone]; + } as Record)[tone]; return ( @@ -64,7 +117,7 @@ function Chip({ value, label, tone = 'muted', details }) { ); } -function StatChips({ category }) { +function StatChips({ category }: { category: CatRow }) { const names = billPreview(category.bill_names); return (
    @@ -90,7 +143,7 @@ function StatChips({ category }) { } function ChipLegend() { - const items = [ + const items: [string, string][] = [ ['Active', 'active'], ['Inactive', 'muted'], ['Payments', 'info'], @@ -113,7 +166,7 @@ function ChipLegend() { ); } -function StatusPill({ active }) { +function StatusPill({ active }: { active?: number }) { return ( @@ -145,7 +198,7 @@ function BillName({ bill }) { ); } -function ExpandedBills({ category }) { +function ExpandedBills({ category }: { category: CatRow }) { const bills = category.bills || []; if (!bills.length) { @@ -230,30 +283,30 @@ function ExpandedBills({ category }) { } export default function CategoriesPage() { - const [categories, setCategories] = useState([]); + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); + const [loadError, setLoadError] = useState(null); const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); - const [expanded, setExpanded] = useState(() => new Set()); + const [expanded, setExpanded] = useState>(() => new Set()); const [showEmptyCategories, setShowEmptyCategories] = useState(false); - const [draggingId, setDraggingId] = useState(null); - const [dropTargetId, setDropTargetId] = useState(null); - const [movingCategoryId, setMovingCategoryId] = useState(null); - const addInputRef = useRef(null); + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [movingCategoryId, setMovingCategoryId] = useState(null); + const addInputRef = useRef(null); - const [renameTarget, setRenameTarget] = useState(null); + const [renameTarget, setRenameTarget] = useState(null); const [renaming, setRenaming] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const load = useCallback(async () => { setLoadError(null); try { - const cats = await api.categories(); + const cats = await api.categories() as CatRow[]; setCategories(cats); } catch (err) { - setLoadError(err.message || 'Failed to load categories'); + setLoadError(errMessage(err, 'Failed to load categories')); } finally { setLoading(false); } @@ -261,7 +314,7 @@ export default function CategoriesPage() { useEffect(() => { load(); }, [load]); - function toggleCategory(id) { + function toggleCategory(id: number) { setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); @@ -270,7 +323,7 @@ export default function CategoriesPage() { }); } - async function handleAdd(e) { + async function handleAdd(e: React.FormEvent) { e.preventDefault(); const trimmed = newName.trim(); if (!trimmed) { @@ -287,18 +340,19 @@ export default function CategoriesPage() { addInputRef.current?.focus(); load(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err, 'Failed to add category')); } finally { setAdding(false); } } - function openRename(event, cat) { + function openRename(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setRenameTarget(cat); } - async function handleRename(name) { + async function handleRename(name: string) { + if (!renameTarget) return; setRenaming(true); try { await api.updateCategory(renameTarget.id, { name }); @@ -306,22 +360,23 @@ export default function CategoriesPage() { setRenameTarget(null); load(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err, 'Failed to rename category')); } finally { setRenaming(false); } } - function openDelete(event, cat) { + function openDelete(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setDeleteTarget(cat); } async function handleDelete() { + const category = deleteTarget; + if (!category) return; setDeleting(true); try { - const category = deleteTarget; - await api.deleteCategory(deleteTarget.id); + await api.deleteCategory(category.id); toast.success(`"${category.name}" moved to recovery`, { description: 'Bills keep their category if you restore it within 30 days.', action: { @@ -332,7 +387,7 @@ export default function CategoriesPage() { toast.success(`"${category.name}" restored`); load(); } catch (err) { - toast.error(err.message || 'Failed to restore category'); + toast.error(errMessage(err, 'Failed to restore category')); } }, }, @@ -345,7 +400,7 @@ export default function CategoriesPage() { setDeleteTarget(null); load(); } catch (err) { - toast.error(err.message || 'Could not delete category.'); + toast.error(errMessage(err, 'Could not delete category.')); } finally { setDeleting(false); } @@ -360,7 +415,7 @@ export default function CategoriesPage() { : categories.filter(cat => categoryBillCount(cat) > 0); const reorderEnabled = !loading && !loadError; - async function persistCategoryOrder(nextCategories, movedId) { + async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) { setCategories(nextCategories); setMovingCategoryId(movedId); try { @@ -368,24 +423,24 @@ export default function CategoriesPage() { toast.success('Category order saved'); load(); } catch (err) { - toast.error(err.message || 'Failed to save category order'); + toast.error(errMessage(err, 'Failed to save category order')); load(); } finally { setMovingCategoryId(null); } } - function reorderVisibleCategories(orderedVisible) { + function reorderVisibleCategories(orderedVisible: CatRow[]) { if (!reorderEnabled) return; const visibleIds = new Set(visibleCategories.map(cat => cat.id)); const replacements = [...orderedVisible]; const nextCategories = categories.map(cat => ( - visibleIds.has(cat.id) ? replacements.shift() : cat + visibleIds.has(cat.id) ? replacements.shift()! : cat )); persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible)); } - function categoryMoveControls(cat, index) { + function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls { return { enabled: reorderEnabled, moving: movingCategoryId === cat.id, @@ -402,7 +457,7 @@ export default function CategoriesPage() { }; } - function categoryDragProps(cat, index) { + function categoryDragProps(cat: CatRow, index: number): CategoryDragProps { if (!reorderEnabled) return { draggable: false }; return { draggable: true, @@ -457,12 +512,12 @@ export default function CategoriesPage() {
    - {[ + {([ ['Categories', categories.length], ['Active bills', activeBills], ['Inactive', inactiveBills], ['Payments', paymentCount], - ].map(([label, value]) => ( + ] as [string, number][]).map(([label, value]) => (

    {label}

    {value}

    @@ -652,10 +707,10 @@ export default function CategoriesPage() { onClick={async (event) => { event.stopPropagation(); try { - const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled); + const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled) as { spending_enabled?: number | boolean }; setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c)); } catch (err) { - toast.error(err.message || 'Failed to update category'); + toast.error(errMessage(err, 'Failed to update category')); } }} aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`} diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.tsx similarity index 85% rename from client/pages/DataPage.jsx rename to client/pages/DataPage.tsx index 74c60c1..06d6235 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from 'react'; +import { useState, useEffect, useCallback, useRef, Suspense, lazy, type ComponentType, type ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; @@ -7,9 +7,10 @@ import { FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, ShieldAlert, } from 'lucide-react'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import ConnectionHero from '@/components/data/ConnectionHero'; -import DataNav from '@/components/data/DataNav'; +import DataNav, { type DataNavSection } from '@/components/data/DataNav'; import BankSyncSection from '@/components/data/BankSyncSection'; import BillRulesManager from '@/components/BillRulesManager'; import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection'; @@ -17,28 +18,44 @@ import ImportOfxSection from '@/components/data/ImportOfxSection'; import TransactionMatchingSection from '@/components/data/TransactionMatchingSection'; import SeedDemoDataSection from '@/components/data/SeedDemoDataSection'; import DownloadMyDataSection from '@/components/data/DownloadMyDataSection'; -import ImportHistorySection from '@/components/data/ImportHistorySection'; +import ImportHistorySection, { type ImportHistoryRow } from '@/components/data/ImportHistorySection'; import EraseDataSection from '@/components/data/EraseDataSection'; // Heavy panes (XLSX parsing / SQLite restore) β€” code-split, loaded on demand. const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection')); const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection')); -const SECTIONS = [ +interface Conn { + id: number | string; + name?: string; + provider?: string; + status?: string; + last_error?: string | null; + last_sync_at?: string | null; +} + +interface Section { + id: string; + label: string; + description: string; + icon: ComponentType<{ className?: string }>; +} + +const SECTIONS: Section[] = [ { id: 'bank-sync', label: 'Bank sync', description: 'Connect & sync your bank', icon: Landmark }, { id: 'transactions', label: 'Transactions', description: 'Review & match', icon: ArrowRightLeft }, { id: 'import', label: 'Import', description: 'Bring in existing data', icon: Upload }, { id: 'export', label: 'Export & backups', description: 'Download & restore', icon: DatabaseBackup }, ]; const SECTION_IDS = SECTIONS.map(s => s.id); -const DEFAULT_SECTION = SECTIONS[0].id; +const DEFAULT_SECTION = SECTIONS[0]!.id; const SECTION_KEY = 'billtracker:data.section'; const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key β†’ migrate -function storedSection() { +function storedSection(): string | null { if (typeof window === 'undefined') return null; const s = window.localStorage.getItem(SECTION_KEY); - return SECTION_IDS.includes(s) ? s : null; + return s && SECTION_IDS.includes(s) ? s : null; } function PaneSkeleton() { @@ -52,25 +69,25 @@ function PaneSkeleton() { export default function DataPage() { const [searchParams, setSearchParams] = useSearchParams(); - const [history, setHistory] = useState(null); + const [history, setHistory] = useState(null); const [historyLoading, setHistoryLoading] = useState(true); const [transactionRefreshKey, setTransactionRefreshKey] = useState(0); - const [simplefinConn, setSimplefinConn] = useState(null); + const [simplefinConn, setSimplefinConn] = useState(null); const [syncEnabled, setSyncEnabled] = useState(true); const [hasConnections, setHasConnections] = useState(false); const [syncLoading, setSyncLoading] = useState(true); const [syncError, setSyncError] = useState(false); const [unmatchedCount, setUnmatchedCount] = useState(0); - const [txnTotal, setTxnTotal] = useState(null); + const [txnTotal, setTxnTotal] = useState(null); const reduceMotion = useReducedMotion(); - const paneRef = useRef(null); + const paneRef = useRef(null); const firstRender = useRef(true); // Active section: URL ?section= is source of truth β†’ localStorage β†’ default. const urlSection = searchParams.get('section'); - const activeSection = SECTION_IDS.includes(urlSection) ? urlSection : (storedSection() || DEFAULT_SECTION); + const activeSection = (urlSection && SECTION_IDS.includes(urlSection)) ? urlSection : (storedSection() || DEFAULT_SECTION); - const goTo = useCallback((id) => { + const goTo = useCallback((id: string) => { if (!SECTION_IDS.includes(id)) return; window.localStorage.setItem(SECTION_KEY, id); setSearchParams(prev => { @@ -83,9 +100,9 @@ export default function DataPage() { // Reflect the resolved section into the URL once, so refresh/back-button work // (and migrate the old 3-tab key). Runs only when the URL lacks a valid section. useEffect(() => { - if (SECTION_IDS.includes(urlSection)) return; + if (urlSection && SECTION_IDS.includes(urlSection)) return; const legacy = window.localStorage.getItem(LEGACY_KEY); - const migrated = { sync: 'bank-sync', import: 'import', export: 'export' }[legacy]; + const migrated = legacy ? ({ sync: 'bank-sync', import: 'import', export: 'export' } as Record)[legacy] : undefined; const target = storedSection() || migrated || DEFAULT_SECTION; setSearchParams(prev => { const p = new URLSearchParams(prev); @@ -104,11 +121,11 @@ export default function DataPage() { const loadHistory = useCallback(async () => { setHistoryLoading(true); try { - const { history } = await api.importHistory(); - setHistory(history); + const res = await api.importHistory() as { history?: ImportHistoryRow[] }; + setHistory(res.history ?? []); } catch (err) { setHistory([]); - toast.error(err.message || 'Failed to load import history.'); + toast.error(errMessage(err, 'Failed to load import history.')); } finally { setHistoryLoading(false); } @@ -119,12 +136,12 @@ export default function DataPage() { setSyncError(false); try { const [status, sources] = await Promise.all([ - api.simplefinStatus(), + api.simplefinStatus() as Promise<{ enabled?: boolean; has_connections?: boolean }>, api.dataSources({ type: 'provider_sync' }), ]); setSyncEnabled(Boolean(status.enabled)); setHasConnections(Boolean(status.has_connections)); - const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; + const conns = Array.isArray(sources) ? (sources as Conn[]).filter(s => s.provider === 'simplefin') : []; setSimplefinConn(conns[0] || null); } catch { setSyncError(true); @@ -137,7 +154,7 @@ export default function DataPage() { // Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough). const loadTxnStats = useCallback(async () => { try { - const data = await api.bankTransactionsLedger({ limit: 1 }); + const data = await api.bankTransactionsLedger({ limit: 1 }) as { summary?: { unmatched?: number; total?: number } }; setUnmatchedCount(data?.summary?.unmatched || 0); setTxnTotal(data?.summary?.total ?? null); } catch { @@ -154,7 +171,7 @@ export default function DataPage() { }, [loadHistory]); // BankSyncSection reports connect/sync/disconnect changes. - const handleConnectionChange = useCallback((conn) => { + const handleConnectionChange = useCallback((conn: Conn | null) => { setSimplefinConn(conn || null); setHasConnections(Boolean(conn)); setSyncLoading(false); @@ -172,7 +189,7 @@ export default function DataPage() { setTransactionRefreshKey(k => k + 1); }, [loadHistory, loadSimplefinSummary]); - const renderPane = () => { + const renderPane = (): ReactNode => { switch (activeSection) { case 'bank-sync': return ( @@ -250,7 +267,7 @@ export default function DataPage() { // Health dot for Bank sync + "N to review" badge for Transactions. const bankDot = (syncError || !syncEnabled || !hasConnections) ? 'gray' : simplefinConn?.last_error ? 'amber' : 'green'; - const navSections = SECTIONS.map(s => + const navSections: DataNavSection[] = SECTIONS.map(s => s.id === 'bank-sync' ? { ...s, dot: bankDot } : s.id === 'transactions' ? { ...s, badge: unmatchedCount || undefined } : s, diff --git a/client/pages/HealthPage.jsx b/client/pages/HealthPage.tsx similarity index 82% rename from client/pages/HealthPage.jsx rename to client/pages/HealthPage.tsx index 1bb09f9..9c84dab 100644 --- a/client/pages/HealthPage.jsx +++ b/client/pages/HealthPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, @@ -12,10 +12,11 @@ import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import BillModal from '@/components/BillModal'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { makeBillDraft } from '@/lib/billDrafts'; +import type { Bill, Category } from '@/types'; -const FIELD_LABELS = { +const FIELD_LABELS: Record = { due_day: 'Due day', category_id: 'Category', minimum_payment: 'Minimum payment', @@ -23,17 +24,49 @@ const FIELD_LABELS = { interest_rate: 'APR', }; -function severityClass(severity) { +interface Issue { + severity: string; + field: string; + suggestion?: string; +} + +interface HealthBill { + id: number; + name: string; + category_name?: string | null; + due_day?: number | null; + active?: number; + issues: Issue[]; +} + +interface HealthSummary { + audited_bills?: number; + issue_count?: number; + error_count?: number; + warning_count?: number; +} + +interface HealthData { + summary?: HealthSummary; + bills?: HealthBill[]; +} + +interface ModalState { + bill?: Bill | null; + initialBill?: Partial; +} + +function severityClass(severity: string): string { return severity === 'error' ? 'border-destructive/25 bg-destructive/10 text-destructive' : 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300'; } -function severityWeight(severity) { +function severityWeight(severity: string): number { return severity === 'error' ? 0 : 1; } -function issueSummary(issues = []) { +function issueSummary(issues: Issue[] = []): string { const errors = issues.filter(issue => issue.severity === 'error').length; const warnings = issues.length - errors; if (errors && warnings) return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`; @@ -41,7 +74,11 @@ function issueSummary(issues = []) { return `${warnings} warning${warnings === 1 ? '' : 's'}`; } -function StatCard({ label, value, tone = 'default' }) { +function StatCard({ label, value, tone = 'default' }: { + label: string; + value: ReactNode; + tone?: 'default' | 'error' | 'warning' | 'ok'; +}) { return (
    void; + openingBillId: number | null; +}) { const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity)); const opening = openingBillId === bill.id; @@ -119,19 +160,19 @@ function BillIssueCard({ bill, onOpenBill, openingBillId }) { } export default function HealthPage() { - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [includeInactive, setIncludeInactive] = useState(false); - const [categories, setCategories] = useState([]); - const [modal, setModal] = useState(null); - const [openingBillId, setOpeningBillId] = useState(null); + const [categories, setCategories] = useState([]); + const [modal, setModal] = useState(null); + const [openingBillId, setOpeningBillId] = useState(null); const load = useCallback(async () => { setLoading(true); try { - setData(await api.billAudit(includeInactive)); + setData(await api.billAudit(includeInactive) as HealthData); } catch (err) { - toast.error(err.message || 'Could not run bill health check.'); + toast.error(errMessage(err, 'Could not run bill health check.')); } finally { setLoading(false); } @@ -139,17 +180,17 @@ export default function HealthPage() { useEffect(() => { load(); }, [load]); - const openBill = useCallback(async (billId) => { + const openBill = useCallback(async (billId: number) => { setOpeningBillId(billId); try { const [bill, cats] = await Promise.all([ - api.bill(billId), - categories.length ? Promise.resolve(categories) : api.categories(), + api.bill(billId) as Promise, + (categories.length ? Promise.resolve(categories) : api.categories()) as Promise, ]); if (!categories.length) setCategories(cats); setModal({ bill }); } catch (err) { - toast.error(err.message || 'Could not open bill.'); + toast.error(errMessage(err, 'Could not open bill.')); } finally { setOpeningBillId(null); } @@ -170,7 +211,7 @@ export default function HealthPage() { return a.name.localeCompare(b.name); }), [bills]); const hasIssues = sortedBills.length > 0; - const healthTone = useMemo(() => { + const healthTone = useMemo<'error' | 'warning' | 'ok'>(() => { if ((summary.error_count || 0) > 0) return 'error'; if ((summary.warning_count || 0) > 0) return 'warning'; return 'ok'; @@ -260,7 +301,7 @@ export default function HealthPage() { categories={categories} onClose={() => setModal(null)} onSave={handleBillSaved} - onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })} + onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial })} /> )}
    diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.tsx similarity index 87% rename from client/pages/LoginPage.jsx rename to client/pages/LoginPage.tsx index 3275e77..8fc180d 100644 --- a/client/pages/LoginPage.jsx +++ b/client/pages/LoginPage.tsx @@ -1,9 +1,9 @@ - -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { api } from '@/api'; -import { useAuth } from '@/hooks/useAuth'; +import { errMessage } from '@/lib/utils'; +import { useAuth, type User } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -14,6 +14,14 @@ import { const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker'; +interface AuthModeInfo { + auth_mode?: string; + local_enabled?: boolean; + oidc_enabled?: boolean; + oidc_login_url?: string; + oidc_provider_name?: string; +} + export default function LoginPage() { const navigate = useNavigate(); const { setUser, refresh } = useAuth(); @@ -22,41 +30,43 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const [authMode, setAuthMode] = useState(null); // null = still loading + const [authMode, setAuthMode] = useState(null); // null = still loading - const [pendingUser, setPendingUser] = useState(null); + const [pendingUser, setPendingUser] = useState(null); const [showChangePw, setShowChangePw] = useState(false); const [showPrivacy, setShowPrivacy] = useState(false); // TOTP challenge state - const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string + const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string const [totpCode, setTotpCode] = useState(''); const [totpError, setTotpError] = useState(''); const [totpLoading, setTotpLoading] = useState(false); - const [useRecovery, setUseRecovery] = useState(false);; + const [useRecovery, setUseRecovery] = useState(false); const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); const [pwLoading, setPwLoading] = useState(false); - const destFor = (user) => (user?.is_default_admin ? '/admin' : '/'); + const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/'); useEffect(() => { api.authMode() - .then(d => { + .then(raw => { + const d = raw as AuthModeInfo; setAuthMode(d); if (d.auth_mode === 'single') navigate('/', { replace: true }); }) .catch(err => console.error('[LoginPage] authMode check failed', err)); api.me() - .then(d => { + .then(raw => { + const d = raw as { user?: User }; if (d.user) navigate(destFor(d.user), { replace: true }); }) .catch(err => console.error('[LoginPage] session check failed', err)); }, []); // eslint-disable-line - const handlePostLogin = (user) => { + const handlePostLogin = (user: User) => { setUser(user); if (user.must_change_password) { setPendingUser(user); @@ -71,14 +81,14 @@ export default function LoginPage() { } }; - const handleLogin = async (e) => { + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { - const data = await api.login({ username, password }); + const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User }; if (data.requires_totp) { - setTotpChallenge(data.challenge_token); + setTotpChallenge(data.challenge_token ?? null); setTotpCode(''); setTotpError(''); setUseRecovery(false); @@ -86,24 +96,24 @@ export default function LoginPage() { } handlePostLogin(data.user); } catch (err) { - setError(err.message || 'Login failed.'); + setError(errMessage(err, 'Login failed.')); } finally { setLoading(false); } }; - const handleTotpSubmit = async (e) => { + const handleTotpSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTotpError(''); setTotpLoading(true); try { - const payload = { challenge_token: totpChallenge }; + const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge }; if (useRecovery) payload.recovery_code = totpCode; else payload.code = totpCode; - const data = await api.totpChallenge(payload); + const data = await api.totpChallenge(payload) as { user: User }; handlePostLogin(data.user); } catch (err) { - setTotpError(err.message || 'Invalid code.'); + setTotpError(errMessage(err, 'Invalid code.')); setTotpCode(''); } finally { setTotpLoading(false); @@ -115,7 +125,7 @@ export default function LoginPage() { const providerName = authMode?.oidc_provider_name || 'authentik'; const isAuthentikProvider = providerName.toLowerCase().includes('authentik'); - const handleChangePassword = async (e) => { + const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); if (newPw !== confirmPw) { @@ -141,7 +151,7 @@ export default function LoginPage() { navigate(destFor(pendingUser), { replace: true }); } } catch (err) { - toast.error(err.message || 'Failed to change password.'); + toast.error(errMessage(err, 'Failed to change password.')); } finally { setPwLoading(false); } @@ -150,7 +160,7 @@ export default function LoginPage() { const handleAcknowledgePrivacy = async () => { try { await api.acknowledgePrivacy(); - } catch {} + } catch { /* ignore */ } refresh(); setShowPrivacy(false); @@ -249,7 +259,7 @@ export default function LoginPage() { type="button" variant={localEnabled ? 'outline' : 'default'} className="w-full gap-2" - onClick={() => { window.location.href = authMode.oidc_login_url; }} + onClick={() => { window.location.href = authMode?.oidc_login_url || ''; }} > {isAuthentikProvider && ( { e.target.style.display = 'none'; }} + onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} Continue with {providerName} @@ -301,7 +311,7 @@ export default function LoginPage() {
    {error && ( -
    {error}
    diff --git a/client/pages/NotFoundPage.jsx b/client/pages/NotFoundPage.tsx similarity index 93% rename from client/pages/NotFoundPage.jsx rename to client/pages/NotFoundPage.tsx index ab7945e..807909b 100644 --- a/client/pages/NotFoundPage.jsx +++ b/client/pages/NotFoundPage.tsx @@ -5,8 +5,8 @@ import { Button } from '@/components/ui/button'; import { useAuth } from '@/hooks/useAuth'; // Animated digit β€” cycles through random numbers before settling -function GlitchDigit({ value, delay = 0 }) { - const ref = useRef(null); +function GlitchDigit({ value, delay = 0 }: { value: string; delay?: number }) { + const ref = useRef(null); useEffect(() => { const el = ref.current; @@ -15,20 +15,20 @@ function GlitchDigit({ value, delay = 0 }) { const frames = 14; const digits = '0123456789'; let frame = 0; - let start = null; + let start: number | null = null; - function tick(ts) { + function tick(ts: number) { if (!start) start = ts + delay; const elapsed = ts - start; if (elapsed < 0) { requestAnimationFrame(tick); return; } if (frame < frames) { - el.textContent = digits[Math.floor(Math.random() * 10)]; + el!.textContent = digits[Math.floor(Math.random() * 10)] ?? '0'; frame++; setTimeout(() => requestAnimationFrame(tick), 40 + frame * 6); } else { - el.textContent = value; - el.classList.add('settled'); + el!.textContent = value; + el!.classList.add('settled'); } } diff --git a/client/pages/PayoffPage.jsx b/client/pages/PayoffPage.tsx similarity index 93% rename from client/pages/PayoffPage.jsx rename to client/pages/PayoffPage.tsx index f762b40..a32c255 100644 --- a/client/pages/PayoffPage.jsx +++ b/client/pages/PayoffPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; @@ -9,9 +9,16 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { cn } from '@/lib/utils'; -import { formatUSD, formatUSDWhole } from '@/lib/money'; +import { cn, errMessage } from '@/lib/utils'; +import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money'; import PayoffChart from '@/components/snowball/PayoffChart'; +import type { Bill } from '@/types'; + +interface ScheduleMonth { + month: number; + balance: number; + interest: number; +} // ─── Print isolation ────────────────────────────────────────────────────────── @@ -36,20 +43,20 @@ const PRINT_STYLES = ` // ─── Helpers ────────────────────────────────────────────────────────────────── -function fmt(v) { - return formatUSD(v); +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); } -function fmtShort(v) { - return formatUSDWhole(v); +function fmtShort(v: number | null | undefined): string { + return formatUSDWhole(asDollars(Number(v) || 0)); } -function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) { +function buildPayoffSchedule(balance: number, annualRatePct: number, monthlyPayment: number, oneTimeExtra = 0): ScheduleMonth[] { if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return []; const rate = (annualRatePct || 0) / 100 / 12; if (rate > 0 && monthlyPayment <= balance * rate) return []; let bal = balance; - const months = []; + const months: ScheduleMonth[] = []; for (let i = 0; i < 600; i++) { const interest = Math.round(bal * rate * 100) / 100; const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment); @@ -61,13 +68,13 @@ function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtr return months; } -function payoffLabel(track, now = new Date()) { +function payoffLabel(track: ScheduleMonth[], now = new Date()): string | null { if (!track.length) return null; const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1); return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } -function numMonths(track) { +function numMonths(track: ScheduleMonth[]): string | null { if (!track.length) return null; const y = Math.floor(track.length / 12); const m = track.length % 12; @@ -78,8 +85,13 @@ function numMonths(track) { // ─── Sub-components ─────────────────────────────────────────────────────────── -function StatCard({ label, value, sub, color = 'amber' }) { - const colors = { +function StatCard({ label, value, sub, color = 'amber' }: { + label: ReactNode; + value: ReactNode; + sub?: ReactNode; + color?: string; +}) { + const colors: Record = { amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400', teal: 'bg-teal-500/8 border-teal-400/20 text-teal-500 dark:text-teal-400', slate: 'bg-muted/40 border-border/60 text-foreground', @@ -93,7 +105,7 @@ function StatCard({ label, value, sub, color = 'amber' }) { ); } -function InputRow({ label, hint, children }) { +function InputRow({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) { return (
    @@ -136,11 +148,11 @@ function NoSelection() { // ─── PayoffPage ─────────────────────────────────────────────────────────────── export default function PayoffPage() { - const [bills, setBills] = useState([]); + const [bills, setBills] = useState([]); const [extraPayment, setExtraPayment] = useState(0); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [selectedId, setSelectedId] = useState(null); // number | 'custom' | null + const [loadError, setLoadError] = useState(null); + const [selectedId, setSelectedId] = useState(null); // Custom mode inputs const [customName, setCustomName] = useState(''); @@ -158,7 +170,7 @@ export default function PayoffPage() { setLoading(true); setLoadError(null); // Use api.bills() so ALL active bills with a balance appear (not just debt categories) - Promise.all([api.bills(), api.snowballSettings()]) + Promise.all([api.bills() as Promise, api.snowballSettings() as Promise<{ extra_payment?: number }>]) .then(([allBills, settings]) => { const withBalance = (allBills || []) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) @@ -166,10 +178,10 @@ export default function PayoffPage() { setBills(withBalance); setExtraPayment(Number(settings?.extra_payment) || 0); if (withBalance.length > 0 && !selectedId) { - setSelectedId(withBalance[0].id); + setSelectedId(withBalance[0]!.id); } }) - .catch(err => setLoadError(err.message || 'Failed to load data')) + .catch(err => setLoadError(errMessage(err, 'Failed to load data'))) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -205,7 +217,7 @@ export default function PayoffPage() { const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? ''); const { minTrack, currentTrack, simTrack } = useMemo(() => { - if (!activeBalance) return { minTrack: [], currentTrack: [], simTrack: [] }; + if (!activeBalance) return { minTrack: [] as ScheduleMonth[], currentTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] }; const min = !isCustom && minPayment > 0 ? minPayment : 0.01; const currentPmt = !isCustom && isAttack ? min + extraPayment : min; return { @@ -267,7 +279,7 @@ export default function PayoffPage() { } }; - const handleSelectChange = (val) => { + const handleSelectChange = (val: string) => { setSelectedId(val === 'custom' ? 'custom' : Number(val)); }; @@ -302,7 +314,7 @@ export default function PayoffPage() { } const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || - (!isCustom && bill && simTrack.length > 0); + (!isCustom && !!bill && simTrack.length > 0); return ( <> diff --git a/client/pages/PrivacyPage.jsx b/client/pages/PrivacyPage.tsx similarity index 91% rename from client/pages/PrivacyPage.jsx rename to client/pages/PrivacyPage.tsx index 59f7dec..aab4e42 100644 --- a/client/pages/PrivacyPage.jsx +++ b/client/pages/PrivacyPage.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { ArrowLeft, BadgeCheck, Database, EyeOff, FileText, Fingerprint, - LockKeyhole, RadioTower, ShieldCheck, UserRoundCog, + LockKeyhole, RadioTower, ShieldCheck, UserRoundCog, type LucideIcon, } from 'lucide-react'; import { api } from '@/api'; import { useAuth } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -const iconByTitle = { +const iconByTitle: Record = { Overview: ShieldCheck, 'Data Collection': Database, 'Version Checking': RadioTower, @@ -21,15 +21,27 @@ const iconByTitle = { Contact: Fingerprint, }; +interface PrivacySection { + title: string; + items: string[]; +} + +interface PrivacyDoc { + name?: string; + summary?: string; + last_updated?: string; + sections?: PrivacySection[]; +} + export default function PrivacyPage() { const { user } = useAuth(); - const [privacy, setPrivacy] = useState(null); + const [privacy, setPrivacy] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { setLoading(true); try { - setPrivacy(await api.privacy()); + setPrivacy(await api.privacy() as PrivacyDoc); } finally { setLoading(false); } diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.tsx similarity index 85% rename from client/pages/ProfilePage.jsx rename to client/pages/ProfilePage.tsx index a72d9b7..10a318d 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.tsx @@ -1,11 +1,12 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, type ComponentType, type ReactNode } from 'react'; import { toast } from 'sonner'; import { User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock, } from 'lucide-react'; import { api } from '@/api'; -import { useAuth } from '@/hooks/useAuth'; +import { errMessage } from '@/lib/utils'; +import { useAuth, type User as AuthUser } from '@/hooks/useAuth'; import { useAutoSave } from '@/hooks/useAutoSave'; import { SaveStatus } from '@/components/ui/save-status'; import { Button } from '@/components/ui/button'; @@ -15,28 +16,66 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; -function asProfile(data) { - return data?.profile || data?.user || data || {}; +type Settings = Record; + +interface Profile { + username?: string; + display_name?: string; + displayName?: string; + name?: string; + role?: string; + last_password_change_at?: string; + password_changed_at?: string; + [key: string]: unknown; } -function displayNameOf(profile) { +interface LoginEntry { + id: number | string; + success?: boolean; + user_agent?: string; + browser?: string; + os?: string; + device_type?: string; + logged_in_at?: string; + is_current_session?: boolean; + ip_address?: string; + location_city?: string; + location_region?: string; + location_country?: string; + location_isp?: string; + device_fingerprint?: string; +} + +function asProfile(data: unknown): Profile { + const d = data as { profile?: Profile; user?: Profile } | Profile | null | undefined; + return ((d as { profile?: Profile })?.profile || (d as { user?: Profile })?.user || (d as Profile) || {}) as Profile; +} + +function displayNameOf(profile: Profile): string { return profile.display_name || profile.displayName || profile.name || ''; } -export function asSettings(data) { - return data?.settings || data?.notifications || data || {}; +export function asSettings(data: unknown): Settings { + const d = data as { settings?: Settings; notifications?: Settings } | Settings | null | undefined; + return ((d as { settings?: Settings })?.settings || (d as { notifications?: Settings })?.notifications || (d as Settings) || {}) as Settings; } -function formatDateTime(value) { +function formatDateTime(value: unknown): string { if (!value) return 'Not recorded'; - const d = new Date(value); + const d = new Date(value as string | number); if (Number.isNaN(d.getTime())) return String(value); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -function SectionCard({ title, icon: Icon, subtitle, action, children }) { +function SectionCard({ title, icon: Icon, subtitle, action, children }: { + title: ReactNode; + icon: ComponentType<{ className?: string }>; + subtitle?: ReactNode; + action?: ReactNode; + children: ReactNode; +}) { return (
    @@ -54,7 +93,7 @@ function SectionCard({ title, icon: Icon, subtitle, action, children }) { ); } -function FieldRow({ label, value }) { +function FieldRow({ label, value }: { label: ReactNode; value?: ReactNode }) { return (

    {label}

    @@ -63,7 +102,13 @@ function FieldRow({ label, value }) { ); } -function CheckRow({ id, label, checked, onChange, disabled }) { +function CheckRow({ id, label, checked, onChange, disabled }: { + id: string; + label: ReactNode; + checked?: boolean; + onChange?: (v: boolean) => void; + disabled?: boolean; +}) { return (