diff --git a/frontend/.gitignore b/frontend/.gitignore index 7324fd2..70d87e7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Playwright test-results diff --git a/frontend/e2e/breakdown.spec.js b/frontend/e2e/breakdown.spec.js index a91dddc..ffd066a 100644 --- a/frontend/e2e/breakdown.spec.js +++ b/frontend/e2e/breakdown.spec.js @@ -26,3 +26,13 @@ test("marks an over-budget category in red", async ({ page }) => { const rentRow = page.locator("button").filter({ hasText: "Rent" }); await expect(rentRow.locator("span").filter({ hasText: "$" }).last()).toHaveClass(/text-red-600/); }); + +test("expanding 'view more' categories persists across an Expenses/Income tab switch", async ({ page }) => { + await page.getByText(/View \d+ more/).click(); + await expect(page.getByText("View less")).toBeVisible(); + + await page.getByRole("tab", { name: "Income" }).click(); + await page.getByRole("tab", { name: "Expenses" }).click(); + + await expect(page.getByText("View less")).toBeVisible(); +}); diff --git a/frontend/e2e/expenses.spec.js b/frontend/e2e/expenses.spec.js index 7ccc1d4..5e9bc3a 100644 --- a/frontend/e2e/expenses.spec.js +++ b/frontend/e2e/expenses.spec.js @@ -23,6 +23,19 @@ test("opens the edit dialog pre-filled with the expense's existing details", asy await expect(dialog.getByRole("combobox")).toContainText("Dining"); }); +test("reopening the same expense after Cancel shows its current values, not the unsaved edit", async ({ page }) => { + await expenseRow(page, "Gas at Shell").click(); + const dialog = page.getByRole("dialog"); + const amountInput = dialog.locator('input[type="number"]'); + await expect(amountInput).toHaveValue("54.2"); + + await amountInput.fill("999"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await expenseRow(page, "Gas at Shell").click(); + await expect(dialog.locator('input[type="number"]')).toHaveValue("54.2"); +}); + test("splitting an amount divides it and updates the input", async ({ page }) => { await expenseRow(page, "Dinner at Pasta House").click(); const dialog = page.getByRole("dialog"); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 528451e..e52065b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -2,14 +2,16 @@ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), { - files: ['**/*.{js,jsx}'], + files: ['**/*.{js,jsx,ts,tsx}'], extends: [ js.configs.recommended, + ...tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd8dea4..b92f9dd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,8 @@ "globals": "^17.6.0", "postcss": "^8.5.15", "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", "vite": "^8.0.12" } }, @@ -3317,6 +3319,249 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -8638,6 +8883,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -8721,6 +8979,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a03845c..29a800d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "test:e2e": "playwright test" @@ -41,6 +41,8 @@ "globals": "^17.6.0", "postcss": "^8.5.15", "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", "vite": "^8.0.12" } } diff --git a/frontend/src/components/BudgetDialog.tsx b/frontend/src/components/BudgetDialog.tsx new file mode 100644 index 0000000..bd1c763 --- /dev/null +++ b/frontend/src/components/BudgetDialog.tsx @@ -0,0 +1,209 @@ +import { useState, type ChangeEvent } from "react"; +import { toast } from "sonner"; +import { Wallet } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerTrigger } from "@/components/ui/drawer"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { CategoryBadge } from "@/components/CategoryVisuals"; +import type { AuthFetch } from "@/types"; + +const valuesFromBudgetMap = (categories: string[], budgetMap: Record): Record => { + const values: Record = {}; + categories.forEach((c) => { values[c] = budgetMap[c] != null ? String(budgetMap[c]) : ""; }); + return values; +}; + +const sortCategoriesByBudget = (categories: string[], budgetMap: Record): string[] => { + const budgeted: string[] = []; + const unbudgeted: string[] = []; + categories.forEach((c) => (budgetMap[c] != null ? budgeted : unbudgeted).push(c)); + budgeted.sort((a, b) => a.localeCompare(b)); + unbudgeted.sort((a, b) => a.localeCompare(b)); + return [...budgeted, ...unbudgeted]; +}; + +interface BudgetSettingsProps { + categories: string[]; + budgetMap: Record; + spendByCategory?: Record; + authFetch: AuthFetch; + onSaved: () => void; +} + +const BudgetSettings = ({ categories, budgetMap, spendByCategory, authFetch, onSaved }: BudgetSettingsProps) => { + const [values, setValues] = useState(() => valuesFromBudgetMap(categories, budgetMap)); + const [syncedBudgetMap, setSyncedBudgetMap] = useState(budgetMap); + const [orderedCategories, setOrderedCategories] = useState(() => sortCategoriesByBudget(categories, budgetMap)); + + if (budgetMap !== syncedBudgetMap) { + setSyncedBudgetMap(budgetMap); + setValues(valuesFromBudgetMap(categories, budgetMap)); + setOrderedCategories(sortCategoriesByBudget(categories, budgetMap)); + } + + const handleBlur = async (category: string) => { + const original = budgetMap[category] ?? null; + const trimmed = (values[category] ?? "").trim(); + + if (trimmed === "") { + if (original == null) return; + try { + const res = await authFetch(`/budgets/${encodeURIComponent(category)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + onSaved(); + } catch { + toast.error("Failed to remove budget"); + setValues((v) => ({ ...v, [category]: String(original) })); + } + return; + } + + const limit = parseFloat(trimmed); + if (!Number.isFinite(limit) || limit <= 0) { + toast.error("Enter a valid budget amount"); + setValues((v) => ({ ...v, [category]: original != null ? String(original) : "" })); + return; + } + if (limit === original) return; + + try { + const res = await authFetch(`/budgets/${encodeURIComponent(category)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ monthly_limit: limit }), + }); + if (!res.ok) throw new Error(); + onSaved(); + } catch { + toast.error("Failed to save budget"); + setValues((v) => ({ ...v, [category]: original != null ? String(original) : "" })); + } + }; + + return ( +
+ {orderedCategories.map((category) => { + const limit = budgetMap[category]; + const spent = spendByCategory?.[category] ?? 0; + // spendByCategory is omitted entirely when the table is filtered to + // "All time" — its totals would be lifetime, not monthly, spend, so + // suppress the indicator rather than show a misleading comparison. + const hasBudget = limit != null && spendByCategory != null; + const usedPct = hasBudget && limit > 0 ? (spent / limit) * 100 : 0; + const statusLevel = !hasBudget ? "none" : usedPct > 100 ? "over" : usedPct >= 80 ? "near" : "ok"; + const barColor = statusLevel === "over" ? "bg-red-500" : statusLevel === "near" ? "bg-amber-500" : "bg-foreground/40"; + const textColor = statusLevel === "over" + ? "text-red-600 dark:text-red-400" + : statusLevel === "near" + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground"; + return ( +
+
+ + {category} +
+ $ + ) => setValues((v) => ({ ...v, [category]: e.target.value }))} + onBlur={() => handleBlur(category)} + className="h-8 text-sm pl-5 text-right" + /> +
+
+ {hasBudget && ( +
+
+
+
+ ${spent.toFixed(0)} spent +
+ )} +
+ ); + })} +
+ ); +}; + +export interface BudgetDialogProps { + categories: string[]; + budgetMap: Record; + spendByCategory?: Record; + authFetch: AuthFetch; + onSaved: () => void; +} + +export default function BudgetDialog({ categories, budgetMap, spendByCategory, authFetch, onSaved }: BudgetDialogProps) { + const [open, setOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const trigger = ( + + ); + + const form = ( + + ); + + if (isDesktop) { + return ( + + {trigger} + { + // Don't let Radix autofocus the first budget input — that pops + // the mobile keyboard immediately on open. Focus the dialog + // container itself instead (it's a valid, non-input focus + // target per the ARIA dialog pattern) so keyboard/screen-reader + // users still land inside the dialog rather than nowhere. + e.preventDefault(); + (e.currentTarget as HTMLElement).focus(); + }} + > + + Manage Budgets + Set monthly spending limits per category + + {form} + + + ); + } + + return ( + + {trigger} + + + Manage Budgets + Set monthly spending limits per category + +
{form}
+
+
+ ); +} diff --git a/frontend/src/components/CategoryBreakdown.tsx b/frontend/src/components/CategoryBreakdown.tsx new file mode 100644 index 0000000..a9273aa --- /dev/null +++ b/frontend/src/components/CategoryBreakdown.tsx @@ -0,0 +1,75 @@ +import { BreakdownRow } from "@/components/CategoryVisuals"; +import BudgetDialog from "@/components/BudgetDialog"; +import type { AuthFetch, BreakdownEntry } from "@/types"; + +export interface CategoryBreakdownProps { + breakdown: BreakdownEntry[]; + maxCategoryTotal: number; + categoryFilter: string | null; + onCategoryClick: (category: string) => void; + categories: string[]; + budgetMap: Record; + spendByCategory?: Record; + authFetch: AuthFetch; + onBudgetSaved: () => void; + // Lifted to the parent (rather than local state here) because this + // component unmounts whenever the Expenses/Income toggle switches away + // from Expenses — local state would silently reset on every tab switch. + showAllCategories: boolean; + onToggleShowAllCategories: () => void; +} + +export default function CategoryBreakdown({ + breakdown, + maxCategoryTotal, + categoryFilter, + onCategoryClick, + categories, + budgetMap, + spendByCategory, + authFetch, + onBudgetSaved, + showAllCategories, + onToggleShowAllCategories, +}: CategoryBreakdownProps) { + if (breakdown.length === 0) return null; + + return ( +
+
+
Breakdown
+ +
+
+ {(showAllCategories ? breakdown : breakdown.slice(0, 5)).map(({ category, amount, count, pct, barPct, limit }) => ( + onCategoryClick(category)} + /> + ))} +
+ {breakdown.length > 5 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/ExpenseEditDialog.tsx b/frontend/src/components/ExpenseEditDialog.tsx new file mode 100644 index 0000000..b249f45 --- /dev/null +++ b/frontend/src/components/ExpenseEditDialog.tsx @@ -0,0 +1,148 @@ +import { useState, type ChangeEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Split } from "lucide-react"; +import type { Expense, EditValues } from "@/types"; + +const valuesFromExpense = (expense: Expense | null): EditValues => ({ + amount: expense?.amount ?? 0, + category: expense?.category ?? "", + description: expense?.description ?? "", + date: expense?.date ?? "", + flagged: !!expense?.flagged, +}); + +export interface ExpenseEditDialogProps { + // The expense currently being edited, or null/undefined when the dialog + // is closed. The Dialog itself stays mounted regardless (open is derived + // from this) so Radix can still play its close animation. + expense: Expense | null | undefined; + categories: string[]; + onSave: (values: EditValues) => void; + onDelete: () => void; + onClose: () => void; +} + +export default function ExpenseEditDialog({ expense, categories, onSave, onDelete, onClose }: ExpenseEditDialogProps) { + const normalizedExpense = expense ?? null; + const isOpen = !!normalizedExpense; + const [values, setValues] = useState(() => valuesFromExpense(normalizedExpense)); + // Tracks whether the dialog was open on the previous render, so form state + // resyncs on every *transition* into open — not just when a different + // expense object reference comes in. Re-opening the same row after a + // cancelled edit (no save happened, so `items` and this object reference + // are unchanged) must still pick up its current values, not the stale + // unsaved edit from last time. Staying open across re-renders, or going + // back to null on close, deliberately does NOT resync — the former would + // blow away in-progress typing, the latter would flash the fields empty + // while the dialog is still playing its exit animation. + const [wasOpen, setWasOpen] = useState(isOpen); + + if (isOpen && !wasOpen) { + setValues(valuesFromExpense(normalizedExpense)); + } + if (isOpen !== wasOpen) { + setWasOpen(isOpen); + } + + return ( + { if (!open) onClose(); }}> + { + // Same rationale as BudgetDialog: skip autofocusing the + // Description input (avoids an immediate mobile keyboard pop), + // but still move focus into the dialog itself rather than + // leaving it stranded on the now-inert trigger behind the modal. + e.preventDefault(); + (e.currentTarget as HTMLElement).focus(); + }} + > + + Edit Expense + Edit the details of this expense + +
+
+ + ) => setValues({ ...values, description: e.target.value })} /> +
+
+ + ) => setValues({ ...values, date: e.target.value })} /> +
+
+
+ + + + + + + {[2, 3, 4].map((n) => ( + setValues((v) => { + const amt = v.amount; + return Number.isFinite(amt) ? { ...v, amount: Math.round((amt / n) * 100) / 100 } : v; + })} + > + Split {["½", "⅓", "¼"][n - 2]} ({n} ways) + + ))} + + +
+ ) => setValues({ ...values, amount: parseFloat(e.target.value) })} /> +
+
+ + +
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ExpenseList.tsx b/frontend/src/components/ExpenseList.tsx new file mode 100644 index 0000000..44158bb --- /dev/null +++ b/frontend/src/components/ExpenseList.tsx @@ -0,0 +1,269 @@ +import { useRef, useState, type MouseEvent, type ReactNode, type TouchEvent } from "react"; +import { Flag, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { CategoryBadge } from "@/components/CategoryVisuals"; +import { formatDate, formatSectionDate } from "@/components/expenseTableFormat"; +import type { Expense } from "@/types"; + +const ExpenseSkeleton = () => ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+); + +const SWIPE_MAX = 88; +const SWIPE_THRESHOLD = 64; + +interface SwipeableRowProps { + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + children: ReactNode; +} + +const SwipeableRow = ({ onSwipeLeft, onSwipeRight, children }: SwipeableRowProps) => { + const [dx, setDx] = useState(0); + const [dragging, setDragging] = useState(false); + const startRef = useRef({ x: 0, y: 0 }); + const axisRef = useRef<"x" | "y" | null>(null); + + const onTouchStart = (e: TouchEvent) => { + const t = e.touches[0]; + startRef.current = { x: t.clientX, y: t.clientY }; + axisRef.current = null; + setDragging(false); + }; + + const onTouchMove = (e: TouchEvent) => { + const t = e.touches[0]; + const deltaX = t.clientX - startRef.current.x; + const deltaY = t.clientY - startRef.current.y; + + if (axisRef.current === null && (Math.abs(deltaX) > 8 || Math.abs(deltaY) > 8)) { + axisRef.current = Math.abs(deltaX) > Math.abs(deltaY) ? "x" : "y"; + } + if (axisRef.current !== "x") return; + + setDragging(true); + const clamped = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, deltaX)); + setDx(clamped); + }; + + const onTouchEnd = () => { + if (dx >= SWIPE_THRESHOLD) onSwipeRight?.(); + else if (dx <= -SWIPE_THRESHOLD) onSwipeLeft?.(); + setDragging(false); + setDx(0); + }; + + const pastRight = dx >= SWIPE_THRESHOLD; + const pastLeft = dx <= -SWIPE_THRESHOLD; + + return ( +
+
+
+ +
+
+ +
+
+
+ {children} +
+
+ ); +}; + +export interface ExpenseListProps { + loading: boolean; + filtered: Expense[]; + emptyMessage: string; + highlightIds?: Set; + userFilter: string | null; + onUserFilterToggle: (user: string) => void; + onEdit: (expense: Expense) => void; + onToggleFlag: (expense: Expense, ev?: MouseEvent) => void; + onDelete: (id: number) => void; +} + +export default function ExpenseList({ + loading, + filtered, + emptyMessage, + highlightIds, + userFilter, + onUserFilterToggle, + onEdit, + onToggleFlag, + onDelete, +}: ExpenseListProps) { + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const confirmSwipeDelete = () => { + if (deleteConfirm) onDelete(deleteConfirm.id); + setDeleteConfirm(null); + }; + + const groupedByDate = filtered.reduce<{ date: string; items: Expense[] }[]>((groups, e) => { + const last = groups[groups.length - 1]; + if (!last || last.date !== e.date) groups.push({ date: e.date, items: [e] }); + else last.items.push(e); + return groups; + }, []); + + return ( + <> + {/* Swipe-to-delete confirmation — mobile only */} + { if (!open) setDeleteConfirm(null); }}> + + + Delete Expense + + Delete "{deleteConfirm?.description}" — ${deleteConfirm?.amount.toFixed(2)}? + + + + + + + + + + {/* ── Mobile card list ── */} +
+ {loading ? ( + + ) : filtered.length === 0 ? ( +

{emptyMessage}

+ ) : groupedByDate.map(({ date, items }) => ( +
+
+ {formatSectionDate(date)} +
+ {items.map((e) => ( + onToggleFlag(e)} onSwipeLeft={() => setDeleteConfirm(e)}> +
onEdit(e)} + > + onUserFilterToggle(u)} + userActive={userFilter === e.logged_by} + /> + {e.description} + {e.reimbursed && ( + + Reimbursed + + )} + ${e.amount.toFixed(2)} + +
+
+ ))} +
+ ))} +
+ + {/* ── Desktop table ── */} + + + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((e) => ( + onEdit(e)} + > + + + + + + + + ))} + +
DateDescriptionCategoryAmount
{emptyMessage}
{formatDate(e.date)} + + {e.description} + {e.reimbursed && ( + + Reimbursed + + )} + + + onUserFilterToggle(u)} + userActive={userFilter === e.logged_by} + /> + ${e.amount.toFixed(2)} + + + +
+ + ); +} diff --git a/frontend/src/components/ExpenseTable.jsx b/frontend/src/components/ExpenseTable.jsx deleted file mode 100644 index cb9cbc9..0000000 --- a/frontend/src/components/ExpenseTable.jsx +++ /dev/null @@ -1,1056 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { X, ArrowUp, Flag, Trash2, Wallet, Search, ChevronDown, Split } from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog"; -import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerTrigger } from "@/components/ui/drawer"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { useMediaQuery } from "@/hooks/use-media-query"; -import { CategoryBadge, BreakdownRow } from "@/components/CategoryVisuals"; -import { useAnimatedNumber } from "@/lib/categoryVisuals"; - -const formatDate = (d) => - new Date(d + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric" }); - -const formatSectionDate = (d) => { - const today = new Date().toISOString().slice(0, 10); - const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); - if (d === today) return "Today"; - if (d === yesterday) return "Yesterday"; - return new Date(d + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" }); -}; - -const formatMonth = (ym) => { - const [year, month] = ym.split("-"); - return new Date(year, month - 1).toLocaleString("default", { month: "long", year: "numeric" }); -}; - -const ExpenseSkeleton = () => ( -
- {[0, 1, 2, 3, 4].map((i) => ( -
- ))} -
-); - -const SWIPE_MAX = 88; -const SWIPE_THRESHOLD = 64; - -const SwipeableRow = ({ onSwipeLeft, onSwipeRight, children }) => { - const [dx, setDx] = useState(0); - const [dragging, setDragging] = useState(false); - const startRef = useRef({ x: 0, y: 0 }); - const axisRef = useRef(null); // "x" | "y" | null - - const onTouchStart = (e) => { - const t = e.touches[0]; - startRef.current = { x: t.clientX, y: t.clientY }; - axisRef.current = null; - setDragging(false); - }; - - const onTouchMove = (e) => { - const t = e.touches[0]; - const deltaX = t.clientX - startRef.current.x; - const deltaY = t.clientY - startRef.current.y; - - if (axisRef.current === null && (Math.abs(deltaX) > 8 || Math.abs(deltaY) > 8)) { - axisRef.current = Math.abs(deltaX) > Math.abs(deltaY) ? "x" : "y"; - } - if (axisRef.current !== "x") return; - - setDragging(true); - const clamped = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, deltaX)); - setDx(clamped); - }; - - const onTouchEnd = () => { - if (dx >= SWIPE_THRESHOLD) onSwipeRight?.(); - else if (dx <= -SWIPE_THRESHOLD) onSwipeLeft?.(); - setDragging(false); - setDx(0); - }; - - const pastRight = dx >= SWIPE_THRESHOLD; - const pastLeft = dx <= -SWIPE_THRESHOLD; - - return ( -
-
-
- -
-
- -
-
-
- {children} -
-
- ); -}; - -const valuesFromBudgetMap = (categories, budgetMap) => { - const values = {}; - categories.forEach((c) => { values[c] = budgetMap[c] != null ? String(budgetMap[c]) : ""; }); - return values; -}; - -const sortCategoriesByBudget = (categories, budgetMap) => { - const budgeted = []; - const unbudgeted = []; - categories.forEach((c) => (budgetMap[c] != null ? budgeted : unbudgeted).push(c)); - budgeted.sort((a, b) => a.localeCompare(b)); - unbudgeted.sort((a, b) => a.localeCompare(b)); - return [...budgeted, ...unbudgeted]; -}; - -const BudgetSettings = ({ categories, budgetMap, spendByCategory, authFetch, onSaved }) => { - const [values, setValues] = useState(() => valuesFromBudgetMap(categories, budgetMap)); - const [syncedBudgetMap, setSyncedBudgetMap] = useState(budgetMap); - const [orderedCategories, setOrderedCategories] = useState(() => sortCategoriesByBudget(categories, budgetMap)); - - if (budgetMap !== syncedBudgetMap) { - setSyncedBudgetMap(budgetMap); - setValues(valuesFromBudgetMap(categories, budgetMap)); - setOrderedCategories(sortCategoriesByBudget(categories, budgetMap)); - } - - const handleBlur = async (category) => { - const original = budgetMap[category] ?? null; - const trimmed = (values[category] ?? "").trim(); - - if (trimmed === "") { - if (original == null) return; - try { - const res = await authFetch(`/budgets/${encodeURIComponent(category)}`, { method: "DELETE" }); - if (!res.ok) throw new Error(); - onSaved(); - } catch { - toast.error("Failed to remove budget"); - setValues((v) => ({ ...v, [category]: String(original) })); - } - return; - } - - const limit = parseFloat(trimmed); - if (!Number.isFinite(limit) || limit <= 0) { - toast.error("Enter a valid budget amount"); - setValues((v) => ({ ...v, [category]: original != null ? String(original) : "" })); - return; - } - if (limit === original) return; - - try { - const res = await authFetch(`/budgets/${encodeURIComponent(category)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ monthly_limit: limit }), - }); - if (!res.ok) throw new Error(); - onSaved(); - } catch { - toast.error("Failed to save budget"); - setValues((v) => ({ ...v, [category]: original != null ? String(original) : "" })); - } - }; - - return ( -
- {orderedCategories.map((category) => { - const limit = budgetMap[category]; - const spent = spendByCategory?.[category] ?? 0; - // spendByCategory is omitted entirely when the table is filtered to - // "All time" — its totals would be lifetime, not monthly, spend, so - // suppress the indicator rather than show a misleading comparison. - const hasBudget = limit != null && spendByCategory != null; - const usedPct = hasBudget && limit > 0 ? (spent / limit) * 100 : 0; - const statusLevel = !hasBudget ? "none" : usedPct > 100 ? "over" : usedPct >= 80 ? "near" : "ok"; - const barColor = statusLevel === "over" ? "bg-red-500" : statusLevel === "near" ? "bg-amber-500" : "bg-foreground/40"; - const textColor = statusLevel === "over" - ? "text-red-600 dark:text-red-400" - : statusLevel === "near" - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground"; - return ( -
-
- - {category} -
- $ - setValues((v) => ({ ...v, [category]: e.target.value }))} - onBlur={() => handleBlur(category)} - className="h-8 text-sm pl-5 text-right" - /> -
-
- {hasBudget && ( -
-
-
-
- ${spent.toFixed(0)} spent -
- )} -
- ); - })} -
- ); -}; - -const BudgetDialog = ({ categories, budgetMap, spendByCategory, authFetch, onSaved }) => { - const [open, setOpen] = useState(false); - const isDesktop = useMediaQuery("(min-width: 768px)"); - - const trigger = ( - - ); - - const form = ( - - ); - - if (isDesktop) { - return ( - - {trigger} - { - // Don't let Radix autofocus the first budget input — that pops - // the mobile keyboard immediately on open. Focus the dialog - // container itself instead (it's a valid, non-input focus - // target per the ARIA dialog pattern) so keyboard/screen-reader - // users still land inside the dialog rather than nowhere. - e.preventDefault(); - e.currentTarget.focus(); - }} - > - - Manage Budgets - Set monthly spending limits per category - - {form} - - - ); - } - - return ( - - {trigger} - - - Manage Budgets - Set monthly spending limits per category - -
{form}
-
-
- ); -}; - -export default function ExpenseTable({ expenses, income = [], className = "", token, username, onExpenseChange, onUnauthorized, loading = false, highlightIds }) { - const authFetch = (url, opts = {}) => { - const res = fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }); - res.then((r) => { if (r.status === 401) onUnauthorized(); }); - return res; - }; - - // "New activity since you left" — entirely derived from the /expenses - // list already being fetched, no new endpoint or push infra. Baseline - // (highest expense id seen) is per-username in localStorage; the first - // time it's set (no stored value yet) we snapshot the current max - // instead of treating all existing history as "new". - const lastSeenKey = `expenses_last_seen_id_${username}`; - const [lastSeenId, setLastSeenId] = useState(() => { - const stored = localStorage.getItem(lastSeenKey); - return stored ? parseInt(stored, 10) : null; - }); - - useEffect(() => { - if (lastSeenId === null && expenses.length > 0) { - const maxId = Math.max(...expenses.map((e) => e.id)); - localStorage.setItem(lastSeenKey, String(maxId)); - // Guarded by lastSeenId === null above, so this runs at most once per - // mount (not a cascading-render loop) — the case react-hooks/set-state- - // in-effect exists to catch. eslint-disable-next-line rather than a - // fake async deferral just to dodge the rule. - // eslint-disable-next-line react-hooks/set-state-in-effect - setLastSeenId(maxId); - } - }, [expenses, lastSeenId, lastSeenKey]); - - const newFromOthers = useMemo( - () => (lastSeenId == null ? [] : expenses.filter((e) => e.logged_by && e.logged_by !== username && e.id > lastSeenId)), - [expenses, lastSeenId, username] - ); - const newActivityNames = [...new Set(newFromOthers.map((e) => e.logged_by))]; - - const dismissNewActivity = () => { - const maxId = Math.max(lastSeenId ?? 0, ...expenses.map((e) => e.id)); - localStorage.setItem(lastSeenKey, String(maxId)); - setLastSeenId(maxId); - }; - - // Read-only Phase 1 income view — toggled alongside the expense list - // rather than as a third top-level mobile tab. - const [view, setView] = useState("expenses"); - const [selectedMonthOverride, setSelectedMonthOverride] = useState(null); - const [flaggedOnly, setFlaggedOnly] = useState(false); - const [editingExpense, setEditingExpense] = useState(null); - const [editValues, setEditValues] = useState({}); - const [categories, setCategories] = useState([]); - const [budgets, setBudgets] = useState([]); - const [recurring, setRecurring] = useState([]); - const [showRecurring, setShowRecurring] = useState(false); - const [overrides, setOverrides] = useState({}); - const [deletedIds, setDeletedIds] = useState(() => new Set()); - const [showAllCategories, setShowAllCategories] = useState(false); - const [categoryFilter, setCategoryFilter] = useState(null); - const [userFilter, setUserFilter] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [deleteConfirm, setDeleteConfirm] = useState(null); - const pendingDeletes = useRef({}); - const listRef = useRef(null); - const [showScrollToTop, setShowScrollToTop] = useState(false); - - const handleListScroll = () => { - const el = listRef.current; - if (!el) return; - setShowScrollToTop(el.scrollTop > 300); - }; - - const scrollToTop = () => { - listRef.current?.scrollTo({ top: 0, behavior: "smooth" }); - }; - - useEffect(() => { - fetch("/categories").then((r) => r.json()).then(setCategories); - }, []); - - const fetchBudgets = () => { - authFetch("/budgets").then((r) => r.json()).then(setBudgets); - }; - - useEffect(() => { - fetchBudgets(); - authFetch("/expenses/recurring").then((r) => r.json()).then(setRecurring); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const items = useMemo(() => { - return expenses - .filter((e) => !deletedIds.has(e.id)) - .map((e) => (overrides[e.id] ? { ...e, ...overrides[e.id] } : e)); - }, [expenses, overrides, deletedIds]); - - const months = useMemo(() => { - const seen = new Set(); - items.forEach((e) => seen.add(e.date.slice(0, 7))); - return Array.from(seen).sort().reverse(); - }, [items]); - - const selectedMonth = selectedMonthOverride ?? (months[0] ?? "all"); - - const monthFlagFiltered = items - .filter((e) => selectedMonth === "all" || e.date.startsWith(selectedMonth)) - .filter((e) => !flaggedOnly || e.flagged); - const userSearchFiltered = monthFlagFiltered - .filter((e) => !userFilter || e.logged_by === userFilter) - .filter((e) => !searchQuery || e.description.toLowerCase().includes(searchQuery.trim().toLowerCase())); - const filtered = userSearchFiltered - .filter((e) => !categoryFilter || e.category === categoryFilter); - const total = filtered.reduce((sum, e) => sum + e.amount, 0); - const animatedTotal = useAnimatedNumber(total); - const emptyMessage = items.length === 0 ? "No expenses yet" : "No expenses match your filters"; - - // Income view has no filters in Phase 1 — total is just the sum of - // whatever the API returned (already sorted date DESC server-side). - const incomeTotal = income.reduce((sum, i) => sum + i.amount, 0); - const animatedIncomeTotal = useAnimatedNumber(incomeTotal); - const displayTotal = view === "expenses" ? animatedTotal : animatedIncomeTotal; - - // Net cash flow, all-time and unfiltered by the month/search controls (those - // only apply to the Expenses view's own total) — uses `items` rather than the - // raw `expenses` prop so an optimistic delete-with-undo is reflected instantly. - const netCashFlow = incomeTotal - items.reduce((sum, e) => sum + e.amount, 0); - const animatedNetCashFlow = useAnimatedNumber(netCashFlow); - - const budgetMap = useMemo(() => { - const map = {}; - budgets.forEach((b) => { map[b.category] = b.monthly_limit; }); - return map; - }, [budgets]); - - const categoryTotals = {}; - const categoryCounts = {}; - userSearchFiltered.forEach((e) => { - categoryTotals[e.category] = (categoryTotals[e.category] || 0) + e.amount; - categoryCounts[e.category] = (categoryCounts[e.category] || 0) + 1; - }); - const categoryGrandTotal = userSearchFiltered.reduce((sum, e) => sum + e.amount, 0); - const maxCategoryTotal = Math.max(0, ...Object.values(categoryTotals)); - const breakdown = Object.entries(categoryTotals) - .map(([category, amount]) => ({ - category, - amount, - count: categoryCounts[category], - pct: categoryGrandTotal ? (amount / categoryGrandTotal) * 100 : 0, - barPct: maxCategoryTotal ? (amount / maxCategoryTotal) * 100 : 0, - limit: selectedMonth !== "all" ? budgetMap[category] : undefined, - })) - .sort((a, b) => b.amount - a.amount); - - const toggleFlag = async (e, ev) => { - ev?.stopPropagation(); - const flagged = !e.flagged; - setOverrides((prev) => ({ ...prev, [e.id]: { ...prev[e.id], flagged } })); - try { - const res = await authFetch(`/expenses/${e.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ flagged }), - }); - if (!res.ok) throw new Error(); - onExpenseChange(); - } catch { - setOverrides((prev) => ({ ...prev, [e.id]: { ...prev[e.id], flagged: e.flagged } })); - toast.error("Failed to update flag"); - } - }; - - const openEdit = (e) => { - setEditingExpense(e); - setEditValues({ amount: e.amount, category: e.category, description: e.description, date: e.date, flagged: !!e.flagged }); - }; - - const saveEdit = async () => { - const id = editingExpense.id; - const original = items.find((x) => x.id === id); - const changes = {}; - for (const key of ["amount", "category", "description", "date", "flagged"]) { - if (editValues[key] !== editingExpense[key]) changes[key] = editValues[key]; - } - setOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...editValues } })); - setEditingExpense(null); - try { - const res = await authFetch(`/expenses/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(changes), - }); - if (!res.ok) throw new Error(); - toast.success("Expense updated"); - onExpenseChange(); - } catch { - setOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...original } })); - toast.error("Failed to update expense"); - } - }; - - const restoreDeleted = (id) => { - clearTimeout(pendingDeletes.current[id]); - delete pendingDeletes.current[id]; - setDeletedIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - }; - - const deleteExpenseById = (id) => { - setDeletedIds((prev) => new Set(prev).add(id)); - - const toastId = toast("Expense deleted", { - action: { label: "Undo", onClick: () => restoreDeleted(id) }, - duration: 5000, - }); - - pendingDeletes.current[id] = setTimeout(async () => { - delete pendingDeletes.current[id]; - try { - const res = await authFetch(`/expenses/${id}`, { method: "DELETE" }); - if (!res.ok) throw new Error(); - onExpenseChange(); - } catch { - setDeletedIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - toast.dismiss(toastId); - toast.error("Failed to delete expense"); - } - }, 5000); - }; - - const deleteExpense = () => { - deleteExpenseById(editingExpense.id); - setEditingExpense(null); - }; - - const confirmSwipeDelete = () => { - deleteExpenseById(deleteConfirm.id); - setDeleteConfirm(null); - }; - - return ( -
- - {/* Edit modal — shared between mobile and desktop */} - { if (!open) setEditingExpense(null); }}> - { - // Same rationale as BudgetDialog: skip autofocusing the - // Description input (avoids an immediate mobile keyboard pop), - // but still move focus into the dialog itself rather than - // leaving it stranded on the now-inert trigger behind the modal. - e.preventDefault(); - e.currentTarget.focus(); - }} - > - - Edit Expense - Edit the details of this expense - -
-
- - setEditValues({ ...editValues, description: e.target.value })} /> -
-
- - setEditValues({ ...editValues, date: e.target.value })} /> -
-
-
- - - - - - - {[2, 3, 4].map((n) => ( - setEditValues((v) => { - const amt = parseFloat(v.amount); - return Number.isFinite(amt) ? { ...v, amount: Math.round((amt / n) * 100) / 100 } : v; - })} - > - Split {["½", "⅓", "¼"][n - 2]} ({n} ways) - - ))} - - -
- setEditValues({ ...editValues, amount: parseFloat(e.target.value) })} /> -
-
- - -
- -
- - -
- - -
-
-
-
- - {/* Swipe-to-delete confirmation — mobile only */} - { if (!open) setDeleteConfirm(null); }}> - - - Delete Expense - - Delete "{deleteConfirm?.description}" — ${deleteConfirm?.amount.toFixed(2)}? - - - - - - - - - - {/* Header */} -
-
-
-
- - -
- {view === "expenses" && categoryFilter && ( - - )} - {view === "expenses" && userFilter && ( - - )} -
- - - Total: ${displayTotal.toFixed(2)} - - = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"} - > - Net: - {animatedNetCashFlow >= 0 ? "+" : "-"}${Math.abs(animatedNetCashFlow).toFixed(2)} - - - -
- - {view === "expenses" && ( -
- - - - -
- - setSearchQuery(e.target.value)} - placeholder="Search…" - className="h-8 text-sm pl-7 pr-7" - /> - {searchQuery && ( - - )} -
-
- )} -
- - {/* New activity since you left — household awareness, derived purely - from data already fetched via /expenses (no new endpoint/infra). */} - {view === "expenses" && newFromOthers.length > 0 && ( -
- - {newFromOthers.length} new expense{newFromOthers.length !== 1 ? "s" : ""} from{" "} - {newActivityNames.join(" & ")} since you were last here - - -
- )} - -
-
- - {/* ── Category breakdown ── */} - {view === "expenses" && breakdown.length > 0 && ( -
-
-
Breakdown
- -
-
- {(showAllCategories ? breakdown : breakdown.slice(0, 5)).map(({ category, amount, count, pct, barPct, limit }) => ( - setCategoryFilter((c) => (c === category ? null : category))} - /> - ))} -
- {breakdown.length > 5 && ( - - )} -
- )} - - {/* ── Recurring charges ── */} - {view === "expenses" && recurring.length > 0 && ( -
- - {showRecurring && ( -
- {recurring.map((r) => ( -
- - {r.description} - - {r.frequency[0].toUpperCase() + r.frequency.slice(1)} - - ${r.amount.toFixed(2)} -
- ))} -
- )} -
- )} - - {/* ── Mobile card list ── */} - {view === "expenses" &&
- {loading ? ( - - ) : filtered.length === 0 ? ( -

{emptyMessage}

- ) : filtered.reduce((groups, e) => { - const last = groups[groups.length - 1]; - if (!last || last.date !== e.date) groups.push({ date: e.date, items: [e] }); - else last.items.push(e); - return groups; - }, []).map(({ date, items }) => ( -
-
- {formatSectionDate(date)} -
- {items.map((e) => ( - toggleFlag(e)} onSwipeLeft={() => setDeleteConfirm(e)}> -
openEdit(e)} - > - setUserFilter((f) => (f === u ? null : u))} - userActive={userFilter === e.logged_by} - /> - {e.description} - {e.reimbursed && ( - - Reimbursed - - )} - ${e.amount.toFixed(2)} - -
-
- ))} -
- ))} -
} - - {/* ── Desktop table ── */} - {view === "expenses" && - - - - - - - - - - - - {loading ? ( - - ) : filtered.length === 0 ? ( - - ) : filtered.map((e) => ( - openEdit(e)} - > - - - - - - - - ))} - -
DateDescriptionCategoryAmount
{emptyMessage}
{formatDate(e.date)} - - {e.description} - {e.reimbursed && ( - - Reimbursed - - )} - - - setUserFilter((f) => (f === u ? null : u))} - userActive={userFilter === e.logged_by} - /> - ${e.amount.toFixed(2)} - - - -
} - - {/* ── Income mobile card list — read-only Phase 1, no swipe/edit/delete ── */} - {view === "income" &&
- {income.length === 0 ? ( -

No income yet

- ) : income.reduce((groups, i) => { - const last = groups[groups.length - 1]; - if (!last || last.date !== i.date) groups.push({ date: i.date, items: [i] }); - else last.items.push(i); - return groups; - }, []).map(({ date, items }) => ( -
-
- {formatSectionDate(date)} -
- {items.map((i) => ( -
- - - {i.description} - {i.reimburses_expense_id && ( - - repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount.toFixed(2)} - - )} - - ${i.amount.toFixed(2)} -
- ))} -
- ))} -
} - - {/* ── Income desktop table — read-only Phase 1, no edit/delete ── */} - {view === "income" && - - - - - - - - - - {income.length === 0 ? ( - - ) : income.map((i) => ( - - - - - - - ))} - -
DateDescriptionCategoryAmount
No income yet
{formatDate(i.date)} - {i.description} - {i.reimburses_expense_id && ( - - repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount.toFixed(2)} - - )} - ${i.amount.toFixed(2)}
} - -
- {showScrollToTop && ( - - )} -
-
- ); -} diff --git a/frontend/src/components/ExpenseTable.tsx b/frontend/src/components/ExpenseTable.tsx new file mode 100644 index 0000000..983c5cf --- /dev/null +++ b/frontend/src/components/ExpenseTable.tsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type MouseEvent } from "react"; +import { toast } from "sonner"; +import { X, ArrowUp, Flag, Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useAnimatedNumber } from "@/lib/categoryVisuals"; +import { formatMonth } from "@/components/expenseTableFormat"; +import ExpenseEditDialog from "@/components/ExpenseEditDialog"; +import ExpenseList from "@/components/ExpenseList"; +import IncomeList from "@/components/IncomeList"; +import CategoryBreakdown from "@/components/CategoryBreakdown"; +import RecurringSection from "@/components/RecurringSection"; +import type { AuthFetch, Budget, EditValues, Expense, Income, RecurringCharge } from "@/types"; + +export interface ExpenseTableProps { + expenses: Expense[]; + income?: Income[]; + className?: string; + token: string; + username: string; + onExpenseChange: () => void; + onUnauthorized: () => void; + loading?: boolean; + highlightIds?: Set; +} + +export default function ExpenseTable({ expenses, income = [], className = "", token, username, onExpenseChange, onUnauthorized, loading = false, highlightIds }: ExpenseTableProps) { + const authFetch: AuthFetch = (url, opts = {}) => { + const res = fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }); + res.then((r) => { if (r.status === 401) onUnauthorized(); }); + return res; + }; + + // "New activity since you left" — entirely derived from the /expenses + // list already being fetched, no new endpoint or push infra. Baseline + // (highest expense id seen) is per-username in localStorage; the first + // time it's set (no stored value yet) we snapshot the current max + // instead of treating all existing history as "new". + const lastSeenKey = `expenses_last_seen_id_${username}`; + const [lastSeenId, setLastSeenId] = useState(() => { + const stored = localStorage.getItem(lastSeenKey); + return stored ? parseInt(stored, 10) : null; + }); + + useEffect(() => { + if (lastSeenId === null && expenses.length > 0) { + const maxId = Math.max(...expenses.map((e) => e.id)); + localStorage.setItem(lastSeenKey, String(maxId)); + // Guarded by lastSeenId === null above, so this runs at most once per + // mount (not a cascading-render loop) — the case react-hooks/set-state- + // in-effect exists to catch. eslint-disable-next-line rather than a + // fake async deferral just to dodge the rule. + // eslint-disable-next-line react-hooks/set-state-in-effect + setLastSeenId(maxId); + } + }, [expenses, lastSeenId, lastSeenKey]); + + const newFromOthers = useMemo( + () => (lastSeenId == null ? [] : expenses.filter((e): e is Expense & { logged_by: string } => !!e.logged_by && e.logged_by !== username && e.id > lastSeenId)), + [expenses, lastSeenId, username] + ); + const newActivityNames = [...new Set(newFromOthers.map((e) => e.logged_by))]; + + const dismissNewActivity = () => { + const maxId = Math.max(lastSeenId ?? 0, ...expenses.map((e) => e.id)); + localStorage.setItem(lastSeenKey, String(maxId)); + setLastSeenId(maxId); + }; + + // Read-only Phase 1 income view — toggled alongside the expense list + // rather than as a third top-level mobile tab. + const [view, setView] = useState<"expenses" | "income">("expenses"); + const [selectedMonthOverride, setSelectedMonthOverride] = useState(null); + const [flaggedOnly, setFlaggedOnly] = useState(false); + const [editingExpense, setEditingExpense] = useState(null); + const [categories, setCategories] = useState([]); + const [budgets, setBudgets] = useState([]); + const [recurring, setRecurring] = useState([]); + const [overrides, setOverrides] = useState>>({}); + const [deletedIds, setDeletedIds] = useState>(() => new Set()); + const [categoryFilter, setCategoryFilter] = useState(null); + const [userFilter, setUserFilter] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + // Lifted here (not local state in CategoryBreakdown/RecurringSection) + // because both of those unmount whenever the Expenses/Income toggle + // switches away from Expenses — local state there would silently reset on + // every tab switch, collapsing a section the user had just expanded. + const [showAllCategories, setShowAllCategories] = useState(false); + const [showRecurring, setShowRecurring] = useState(false); + const pendingDeletes = useRef>>({}); + const listRef = useRef(null); + const [showScrollToTop, setShowScrollToTop] = useState(false); + + const handleListScroll = () => { + const el = listRef.current; + if (!el) return; + setShowScrollToTop(el.scrollTop > 300); + }; + + const scrollToTop = () => { + listRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }; + + useEffect(() => { + fetch("/categories").then((r) => r.json()).then(setCategories); + }, []); + + const fetchBudgets = () => { + authFetch("/budgets").then((r) => r.json()).then(setBudgets); + }; + + useEffect(() => { + fetchBudgets(); + authFetch("/expenses/recurring").then((r) => r.json()).then(setRecurring); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const items = useMemo(() => { + return expenses + .filter((e) => !deletedIds.has(e.id)) + .map((e) => (overrides[e.id] ? { ...e, ...overrides[e.id] } : e)); + }, [expenses, overrides, deletedIds]); + + const months = useMemo(() => { + const seen = new Set(); + items.forEach((e) => seen.add(e.date.slice(0, 7))); + return Array.from(seen).sort().reverse(); + }, [items]); + + const selectedMonth = selectedMonthOverride ?? (months[0] ?? "all"); + + const monthFlagFiltered = items + .filter((e) => selectedMonth === "all" || e.date.startsWith(selectedMonth)) + .filter((e) => !flaggedOnly || e.flagged); + const userSearchFiltered = monthFlagFiltered + .filter((e) => !userFilter || e.logged_by === userFilter) + .filter((e) => !searchQuery || e.description.toLowerCase().includes(searchQuery.trim().toLowerCase())); + const filtered = userSearchFiltered + .filter((e) => !categoryFilter || e.category === categoryFilter); + const total = filtered.reduce((sum, e) => sum + e.amount, 0); + const animatedTotal = useAnimatedNumber(total); + const emptyMessage = items.length === 0 ? "No expenses yet" : "No expenses match your filters"; + + // Income view has no filters in Phase 1 — total is just the sum of + // whatever the API returned (already sorted date DESC server-side). + const incomeTotal = income.reduce((sum, i) => sum + i.amount, 0); + const animatedIncomeTotal = useAnimatedNumber(incomeTotal); + const displayTotal = view === "expenses" ? animatedTotal : animatedIncomeTotal; + + // Net cash flow, all-time and unfiltered by the month/search controls (those + // only apply to the Expenses view's own total) — uses `items` rather than the + // raw `expenses` prop so an optimistic delete-with-undo is reflected instantly. + const netCashFlow = incomeTotal - items.reduce((sum, e) => sum + e.amount, 0); + const animatedNetCashFlow = useAnimatedNumber(netCashFlow); + + const budgetMap = useMemo(() => { + const map: Record = {}; + budgets.forEach((b) => { map[b.category] = b.monthly_limit; }); + return map; + }, [budgets]); + + const categoryTotals: Record = {}; + const categoryCounts: Record = {}; + userSearchFiltered.forEach((e) => { + categoryTotals[e.category] = (categoryTotals[e.category] || 0) + e.amount; + categoryCounts[e.category] = (categoryCounts[e.category] || 0) + 1; + }); + const categoryGrandTotal = userSearchFiltered.reduce((sum, e) => sum + e.amount, 0); + const maxCategoryTotal = Math.max(0, ...Object.values(categoryTotals)); + const breakdown = Object.entries(categoryTotals) + .map(([category, amount]) => ({ + category, + amount, + count: categoryCounts[category], + pct: categoryGrandTotal ? (amount / categoryGrandTotal) * 100 : 0, + barPct: maxCategoryTotal ? (amount / maxCategoryTotal) * 100 : 0, + limit: selectedMonth !== "all" ? budgetMap[category] : undefined, + })) + .sort((a, b) => b.amount - a.amount); + + const toggleFlag = async (e: Expense, ev?: MouseEvent) => { + ev?.stopPropagation(); + const flagged = !e.flagged; + setOverrides((prev) => ({ ...prev, [e.id]: { ...prev[e.id], flagged } })); + try { + const res = await authFetch(`/expenses/${e.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flagged }), + }); + if (!res.ok) throw new Error(); + onExpenseChange(); + } catch { + setOverrides((prev) => ({ ...prev, [e.id]: { ...prev[e.id], flagged: e.flagged } })); + toast.error("Failed to update flag"); + } + }; + + const openEdit = (e: Expense) => { + setEditingExpense(e); + }; + + const saveEdit = async (values: EditValues) => { + if (!editingExpense) return; + const id = editingExpense.id; + const original = items.find((x) => x.id === id); + // Unrolled rather than looped over a key array — a loop needs an unsafe + // cast to write a union-keyed value back into `changes` (TS can't verify + // a same-key read/write correlation across loop iterations), and this is + // few enough fields that the explicit form costs nothing in clarity. + const changes: Partial = {}; + if (values.amount !== editingExpense.amount) changes.amount = values.amount; + if (values.category !== editingExpense.category) changes.category = values.category; + if (values.description !== editingExpense.description) changes.description = values.description; + if (values.date !== editingExpense.date) changes.date = values.date; + if (values.flagged !== editingExpense.flagged) changes.flagged = values.flagged; + setOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...values } })); + setEditingExpense(null); + try { + const res = await authFetch(`/expenses/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes), + }); + if (!res.ok) throw new Error(); + toast.success("Expense updated"); + onExpenseChange(); + } catch { + setOverrides((prev) => ({ ...prev, [id]: { ...prev[id], ...original } })); + toast.error("Failed to update expense"); + } + }; + + const restoreDeleted = (id: number) => { + clearTimeout(pendingDeletes.current[id]); + delete pendingDeletes.current[id]; + setDeletedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + + const deleteExpenseById = (id: number) => { + setDeletedIds((prev) => new Set(prev).add(id)); + + const toastId = toast("Expense deleted", { + action: { label: "Undo", onClick: () => restoreDeleted(id) }, + duration: 5000, + }); + + pendingDeletes.current[id] = setTimeout(async () => { + delete pendingDeletes.current[id]; + try { + const res = await authFetch(`/expenses/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + onExpenseChange(); + } catch { + setDeletedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + toast.dismiss(toastId); + toast.error("Failed to delete expense"); + } + }, 5000); + }; + + const deleteExpense = () => { + if (!editingExpense) return; + deleteExpenseById(editingExpense.id); + setEditingExpense(null); + }; + + return ( +
+ + {/* Edit modal — shared between mobile and desktop */} + setEditingExpense(null)} + /> + + {/* Header */} +
+
+
+
+ + +
+ {view === "expenses" && categoryFilter && ( + + )} + {view === "expenses" && userFilter && ( + + )} +
+ + + Total: ${displayTotal.toFixed(2)} + + = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"} + > + Net: + {animatedNetCashFlow >= 0 ? "+" : "-"}${Math.abs(animatedNetCashFlow).toFixed(2)} + + + +
+ + {view === "expenses" && ( +
+ + + + +
+ + ) => setSearchQuery(e.target.value)} + placeholder="Search…" + className="h-8 text-sm pl-7 pr-7" + type={undefined} + /> + {searchQuery && ( + + )} +
+
+ )} +
+ + {/* New activity since you left — household awareness, derived purely + from data already fetched via /expenses (no new endpoint/infra). */} + {view === "expenses" && newFromOthers.length > 0 && ( +
+ + {newFromOthers.length} new expense{newFromOthers.length !== 1 ? "s" : ""} from{" "} + {newActivityNames.join(" & ")} since you were last here + + +
+ )} + +
+
+ + {/* ── Category breakdown ── */} + {view === "expenses" && ( + setCategoryFilter((c) => (c === category ? null : category))} + categories={categories} + budgetMap={budgetMap} + spendByCategory={selectedMonth !== "all" ? categoryTotals : undefined} + authFetch={authFetch} + onBudgetSaved={fetchBudgets} + showAllCategories={showAllCategories} + onToggleShowAllCategories={() => setShowAllCategories((v) => !v)} + /> + )} + + {/* ── Recurring charges ── */} + {view === "expenses" && ( + setShowRecurring((v) => !v)} + /> + )} + + {/* ── Expense list (mobile cards + desktop table) ── */} + {view === "expenses" && ( + setUserFilter((f) => (f === u ? null : u))} + onEdit={openEdit} + onToggleFlag={toggleFlag} + onDelete={deleteExpenseById} + /> + )} + + {/* ── Income list (mobile cards + desktop table) ── */} + {view === "income" && } + +
+ {showScrollToTop && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/IncomeList.tsx b/frontend/src/components/IncomeList.tsx new file mode 100644 index 0000000..5c92bd1 --- /dev/null +++ b/frontend/src/components/IncomeList.tsx @@ -0,0 +1,79 @@ +import { CategoryBadge } from "@/components/CategoryVisuals"; +import { formatDate, formatSectionDate } from "@/components/expenseTableFormat"; +import type { Income } from "@/types"; + +export interface IncomeListProps { + income: Income[]; +} + +// Read-only Phase 1 income view — no edit/delete UI yet. +export default function IncomeList({ income }: IncomeListProps) { + const groupedByDate = income.reduce<{ date: string; items: Income[] }[]>((groups, i) => { + const last = groups[groups.length - 1]; + if (!last || last.date !== i.date) groups.push({ date: i.date, items: [i] }); + else last.items.push(i); + return groups; + }, []); + + return ( + <> + {/* ── Income mobile card list — read-only Phase 1, no swipe/edit/delete ── */} +
+ {income.length === 0 ? ( +

No income yet

+ ) : groupedByDate.map(({ date, items }) => ( +
+
+ {formatSectionDate(date)} +
+ {items.map((i) => ( +
+ + + {i.description} + {i.reimburses_expense_id && ( + + repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount!.toFixed(2)} + + )} + + ${i.amount.toFixed(2)} +
+ ))} +
+ ))} +
+ + {/* ── Income desktop table — read-only Phase 1, no edit/delete ── */} + + + + + + + + + + + {income.length === 0 ? ( + + ) : income.map((i) => ( + + + + + + + ))} + +
DateDescriptionCategoryAmount
No income yet
{formatDate(i.date)} + {i.description} + {i.reimburses_expense_id && ( + + repays {i.reimburses_expense_description} · ${i.reimburses_expense_amount!.toFixed(2)} + + )} + ${i.amount.toFixed(2)}
+ + ); +} diff --git a/frontend/src/components/RecurringSection.tsx b/frontend/src/components/RecurringSection.tsx new file mode 100644 index 0000000..ff7b132 --- /dev/null +++ b/frontend/src/components/RecurringSection.tsx @@ -0,0 +1,51 @@ +import { ChevronDown } from "lucide-react"; +import { CategoryBadge } from "@/components/CategoryVisuals"; +import type { RecurringCharge } from "@/types"; + +export interface RecurringSectionProps { + recurring: RecurringCharge[]; + // Only used to decide whether this section should hide on mobile while a + // search is active — mirrors the original inline conditional exactly. + searchQuery: string; + // Lifted to the parent (rather than local state here) because this + // component unmounts whenever the Expenses/Income toggle switches away + // from Expenses — local state would silently reset on every tab switch. + showRecurring: boolean; + onToggleShowRecurring: () => void; +} + +export default function RecurringSection({ recurring, searchQuery, showRecurring, onToggleShowRecurring }: RecurringSectionProps) { + if (recurring.length === 0) return null; + + return ( +
+ + {showRecurring && ( +
+ {recurring.map((r) => ( +
+ + {r.description} + + {r.frequency[0].toUpperCase() + r.frequency.slice(1)} + + ${r.amount.toFixed(2)} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/expenseTableFormat.ts b/frontend/src/components/expenseTableFormat.ts new file mode 100644 index 0000000..e096eaf --- /dev/null +++ b/frontend/src/components/expenseTableFormat.ts @@ -0,0 +1,19 @@ +// Date-formatting helpers shared by ExpenseList and IncomeList (both render +// the same "sticky date header" mobile card grouping and the same compact +// desktop-table date column). Split out of ExpenseTable.jsx verbatim. + +export const formatDate = (d: string): string => + new Date(d + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric" }); + +export const formatSectionDate = (d: string): string => { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + if (d === today) return "Today"; + if (d === yesterday) return "Yesterday"; + return new Date(d + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" }); +}; + +export const formatMonth = (ym: string): string => { + const [year, month] = ym.split("-"); + return new Date(Number(year), Number(month) - 1).toLocaleString("default", { month: "long", year: "numeric" }); +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..8031ef4 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,65 @@ +// Shapes shared across the ExpenseTable split (ExpenseTable.tsx and its +// extracted sub-components). Mirrors what agent/db.py's row-builder queries +// actually select (see get_expenses / get_income / get_recurring_charges / +// get_budgets) — only the fields the UI reads, not the full DB row. + +export interface Expense { + id: number; + amount: number; + category: string; + description: string; + date: string; + flagged: boolean; + // Nullable: get_expenses() LEFT JOINs users on a nullable user_id column, + // so an orphaned/missing user produces a null username, not an absent key. + logged_by: string | null; + reimbursed: boolean; +} + +export interface Income { + id: number; + amount: number; + category: string; + description: string; + date: string; + // Nullable for the same reason as Expense.logged_by (get_income() LEFT + // JOINs the same nullable user_id column). + logged_by: string | null; + reimburses_expense_id: number | null; + reimburses_expense_description: string | null; + reimburses_expense_amount: number | null; +} + +export interface RecurringCharge { + description: string; + amount: number; + category: string; + frequency: string; +} + +export interface Budget { + category: string; + monthly_limit: number; +} + +export interface BreakdownEntry { + category: string; + amount: number; + count: number; + pct: number; + barPct: number; + limit?: number; +} + +// Editable fields on an expense — used by ExpenseEditDialog's local form +// state and by the parent's save handler that diffs against the original. +export interface EditValues { + amount: number; + category: string; + description: string; + date: string; + flagged: boolean; +} + +// authFetch: injects the bearer token and triggers onUnauthorized on 401. +export type AuthFetch = (url: string, opts?: RequestInit) => Promise; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..e23b35c --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noFallthroughCasesInSwitch": true, + + "allowJs": true, + + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..95fbf38 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "allowJs": true, + + "strict": true + }, + "include": ["vite.config.js", "playwright.config.js"] +}