From 1b269dedd9da3dd8732eb455c9dd3f0b0543fe40 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 13 Jan 2026 13:38:27 -0500 Subject: [PATCH] Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover --- package-lock.json | 108 ++--- package.json | 3 +- src/app/api/auth/delete-account/route.ts | 25 + src/app/api/auth/reset-password/route.ts | 34 ++ src/app/api/share/account/route.ts | 32 ++ src/app/globals.css | 14 + src/app/layout.tsx | 2 +- src/app/page.tsx | 419 +++++++++++----- src/components/AccountManagement.tsx | 14 +- src/components/AuthModal.tsx | 6 +- src/components/DashboardCharts.tsx | 177 +++++++ src/components/DataInput.tsx | 23 +- src/components/DemoWizard.tsx | 162 +++++++ src/components/EnvelopeEdit.tsx | 10 + src/components/GetPaid.tsx | 457 ++++++++++++++---- src/components/ManualEntry.tsx | 93 +++- .../RecurringTransactionManager.tsx | 101 ++++ src/components/Settings.tsx | 412 ++++++++++++++++ src/components/SetupWizard.tsx | 209 ++++++-- src/components/SharedAccountManager.tsx | 136 ++++++ src/components/TransactionEdit.tsx | 10 +- src/lib/authService.ts | 32 +- src/lib/dataService.ts | 1 + src/lib/fileStorage.ts | 257 ++++++---- 24 files changed, 2265 insertions(+), 472 deletions(-) create mode 100644 src/app/api/auth/delete-account/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/api/share/account/route.ts create mode 100644 src/components/DashboardCharts.tsx create mode 100644 src/components/DemoWizard.tsx create mode 100644 src/components/RecurringTransactionManager.tsx create mode 100644 src/components/Settings.tsx create mode 100644 src/components/SharedAccountManager.tsx diff --git a/package-lock.json b/package-lock.json index 4d86ed4..bd34d4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "budget", "version": "0.1.0", "dependencies": { - "next": "16.0.0", + "next": "16.1.1", "react": "19.2.0", "react-dom": "19.2.0" }, @@ -18,6 +18,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", + "baseline-browser-mapping": "^2.9.14", "eslint": "^9", "eslint-config-next": "16.0.0", "tailwindcss": "^4", @@ -68,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -999,9 +999,9 @@ } }, "node_modules/@next/env": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz", - "integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1015,9 +1015,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz", - "integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", "cpu": [ "arm64" ], @@ -1031,9 +1031,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz", - "integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", "cpu": [ "x64" ], @@ -1047,9 +1047,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz", - "integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", "cpu": [ "arm64" ], @@ -1063,9 +1063,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz", - "integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", "cpu": [ "arm64" ], @@ -1079,9 +1079,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz", - "integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", "cpu": [ "x64" ], @@ -1095,9 +1095,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz", - "integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", "cpu": [ "x64" ], @@ -1111,9 +1111,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz", - "integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", "cpu": [ "arm64" ], @@ -1127,9 +1127,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz", - "integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", "cpu": [ "x64" ], @@ -1525,7 +1525,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1586,7 +1585,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2117,7 +2115,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2404,7 +2401,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -2417,10 +2413,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", - "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", - "dev": true, + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -2470,7 +2465,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3038,7 +3032,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3224,7 +3217,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4428,9 +4420,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4970,13 +4962,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz", - "integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", "dependencies": { - "@next/env": "16.0.0", + "@next/env": "16.1.1", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -4988,14 +4981,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.0", - "@next/swc-darwin-x64": "16.0.0", - "@next/swc-linux-arm64-gnu": "16.0.0", - "@next/swc-linux-arm64-musl": "16.0.0", - "@next/swc-linux-x64-gnu": "16.0.0", - "@next/swc-linux-x64-musl": "16.0.0", - "@next/swc-win32-arm64-msvc": "16.0.0", - "@next/swc-win32-x64-msvc": "16.0.0", + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { @@ -5403,7 +5396,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5413,7 +5405,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6100,7 +6091,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6263,7 +6253,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6539,7 +6528,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d139298..da12870 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "next": "16.0.0", + "next": "16.1.1", "react": "19.2.0", "react-dom": "19.2.0" }, @@ -19,6 +19,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", + "baseline-browser-mapping": "^2.9.14", "eslint": "^9", "eslint-config-next": "16.0.0", "tailwindcss": "^4", diff --git a/src/app/api/auth/delete-account/route.ts b/src/app/api/auth/delete-account/route.ts new file mode 100644 index 0000000..bf2baf1 --- /dev/null +++ b/src/app/api/auth/delete-account/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; + +export async function POST(request: NextRequest) { + try { + const userId = request.headers.get('x-user-id'); + + if (!userId) { + return NextResponse.json( + { error: 'User ID is required' }, + { status: 401 } + ); + } + + // Delete user account and all data + await FileStorageService.deleteUser(userId); + + return NextResponse.json({ success: true, message: 'Account deleted successfully' }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Account deletion failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..36b9cb0 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; + +export async function POST(request: NextRequest) { + try { + const { email, code, newPassword } = await request.json(); + + if (!email || !newPassword) { + return NextResponse.json( + { error: 'Email and new password are required' }, + { status: 400 } + ); + } + + // In production, validate that code matches a sent reset token + // For now, we accept any code for testing purposes + if (!code) { + return NextResponse.json( + { error: 'Reset code is required' }, + { status: 400 } + ); + } + + // Reset password for user + await FileStorageService.resetPassword(email, newPassword); + + return NextResponse.json({ success: true, message: 'Password reset successfully' }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Password reset failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/share/account/route.ts b/src/app/api/share/account/route.ts new file mode 100644 index 0000000..c92a631 --- /dev/null +++ b/src/app/api/share/account/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const userId = request.headers.get('x-user-id'); + const { accountId, email, role } = await request.json(); + + if (!userId || !accountId || !email) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // TODO: Implement actual sharing logic + // 1. Validate email exists + // 2. Create sharing record in database + // 3. Send notification to shared user + // 4. Add shared account to their account list + + return NextResponse.json({ + success: true, + message: `Account shared with ${email} (${role} access)`, + note: 'Sharing feature coming soon - for now this is a placeholder', + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to share account' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..2f70da5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,3 +24,17 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Ensure form controls stay readable across light/dark modes */ +input, +select, +textarea { + color: #111827; + background-color: #ffffff; + border: 1px solid #d1d5db; +} + +input::placeholder, +textarea::placeholder { + color: #9ca3af; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ec725af..7ffc7b0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Envelope Budgeting App", + title: "Capsule by NeCloud", description: "A modern envelope budgeting application to help you manage your finances effectively", }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 6df85b3..e68739c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,6 +11,8 @@ import DataExport from '../components/DataExport'; import AuthModal from '../components/AuthModal'; import SetupWizard from '../components/SetupWizard'; import AccountManagement from '../components/AccountManagement'; +import DashboardCharts from '../components/DashboardCharts'; +import Settings from '../components/Settings'; import GetPaid from '../components/GetPaid'; interface User { @@ -23,6 +25,7 @@ interface Account { name: string; type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan'; balance: number; + startingBalance?: number; institution?: string; accountNumber?: string; color: string; @@ -58,6 +61,7 @@ export default function BudgetDashboard() { const [loading, setLoading] = useState(true); const [showAuthModal, setShowAuthModal] = useState(false); const [showSetupWizard, setShowSetupWizard] = useState(false); + const [showGetPaidModal, setShowGetPaidModal] = useState(false); const [accounts, setAccounts] = useState([]); const [envelopes, setEnvelopes] = useState([]); @@ -81,18 +85,26 @@ export default function BudgetDashboard() { try { const userData = await DataService.loadUserData(); if (userData) { - setAccounts(userData.accounts || []); - setEnvelopes(userData.envelopes || []); // Convert date strings to Date objects - const transactionsWithDates = (userData.transactions || []).map(t => ({ - ...t, - date: typeof t.date === 'string' ? new Date(t.date) : t.date + const transactionsWithDates = (userData.transactions || []).map((transaction) => ({ + ...transaction, + date: typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date, })); + + const accountsWithBaseline = (userData.accounts || []).map((account) => { + const accountTransactions = transactionsWithDates.filter((transaction) => transaction.accountId === account.id); + const transactionTotal = accountTransactions.reduce((sum, transaction) => sum + transaction.amount, 0); + const startingBalance = account.startingBalance ?? account.balance - transactionTotal; + return { ...account, startingBalance, balance: startingBalance + transactionTotal }; + }); + + setAccounts(accountsWithBaseline); + setEnvelopes(userData.envelopes || []); setTransactions(transactionsWithDates); setSetupCompleted(userData.setupCompleted || false); // Show setup wizard if setup is not completed OR if there are no accounts - const shouldShowWizard = !(userData.setupCompleted || false) || (userData.accounts || []).length === 0; + const shouldShowWizard = !(userData.setupCompleted || false) || accountsWithBaseline.length === 0; setShowSetupWizard(shouldShowWizard); } else { // New user - show setup wizard @@ -118,10 +130,10 @@ export default function BudgetDashboard() { return () => unsubscribe(); }, []); - // Save data whenever data changes + // Save data whenever data changes (with debouncing to prevent race conditions) useEffect(() => { if (user && (((accounts || []).length > 0 || (envelopes || []).length > 0 || (transactions || []).length > 0 || setupCompleted))) { - const saveData = async () => { + const debounceTimer = setTimeout(async () => { try { // Recalculate envelope spent amounts before saving const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions); @@ -137,8 +149,9 @@ export default function BudgetDashboard() { } catch (error) { console.error('Error saving user data:', error); } - }; - saveData(); + }, 1000); // Debounce saves by 1 second + + return () => clearTimeout(debounceTimer); } }, [user, accounts, envelopes, transactions, setupCompleted]); @@ -147,33 +160,30 @@ export default function BudgetDashboard() { return envelopes.map(envelope => { const envelopeTransactions = transactions.filter(t => t.envelopeId === envelope.id); const spent = envelopeTransactions.reduce((sum, t) => { - // For envelopes, we track spending as positive amounts - // Negative transaction amounts (expenses) increase spending - return sum + Math.abs(t.amount < 0 ? t.amount : 0); + // Track spending from negative amounts (expenses) + // Positive amounts are income allocations to the envelope + return sum + (t.amount < 0 ? Math.abs(t.amount) : 0); }, 0); + // Keep allocated as-is; income allocations are stored in transactions, not in allocated return { ...envelope, spent }; }); }; // Calculate account balances from transactions const calculateAccountBalances = (accounts: Account[], transactions: Transaction[]): Account[] => { - // Group transactions by account const accountTransactionMap = new Map(); - transactions.forEach(t => { - if (!accountTransactionMap.has(t.accountId)) { - accountTransactionMap.set(t.accountId, []); + transactions.forEach((transaction) => { + if (!accountTransactionMap.has(transaction.accountId)) { + accountTransactionMap.set(transaction.accountId, []); } - accountTransactionMap.get(t.accountId)!.push(t); + accountTransactionMap.get(transaction.accountId)!.push(transaction); }); - return accounts.map(account => { + return accounts.map((account) => { const accountTransactions = accountTransactionMap.get(account.id) || []; - // Sum all transaction amounts (positive for income, negative for expenses) - const transactionTotal = accountTransactions.reduce((sum, t) => sum + t.amount, 0); - // Balance is initial balance plus transaction total - // Note: This assumes the current balance includes initial setup balance - // In a real app, you might want to track initial balance separately - return account; + const transactionTotal = accountTransactions.reduce((sum, transaction) => sum + transaction.amount, 0); + const baseBalance = account.startingBalance ?? account.balance ?? 0; + return { ...account, startingBalance: baseBalance, balance: baseBalance + transactionTotal }; }); }; @@ -181,18 +191,69 @@ export default function BudgetDashboard() { const allTransactions = [...newTransactions]; // Process income transactions - automatically allocate to envelopes + // BUT: only if allocations weren't already created by GetPaid newTransactions.forEach(transaction => { if (transaction.amount > 0 && !transaction.envelopeId) { - // This is income - allocate to envelopes based on custom settings or proportionally - const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId); - const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType); + // Check if this income already has corresponding allocations in newTransactions + const hasExistingAllocations = newTransactions.some( + t => t.accountId === transaction.accountId && + t.envelopeId && + t.description.includes('→') + ); - if (envelopesWithAllocation.length > 0) { - // Validate allocations before processing - const validation = validateIncomeAllocation(envelopesWithAllocation, transaction.amount); - if (!validation.isValid) { - console.warn('Invalid income allocation detected, falling back to proportional'); - // Fall back to proportional allocation if validation fails + // Only create auto-allocations if none exist + if (!hasExistingAllocations) { + // This is income - allocate to envelopes based on custom settings or proportionally + const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId); + const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType); + + if (envelopesWithAllocation.length > 0) { + // Validate allocations before processing + const validation = validateIncomeAllocation(envelopesWithAllocation, transaction.amount); + if (!validation.isValid) { + console.warn('Invalid income allocation detected, falling back to proportional'); + // Fall back to proportional allocation if validation fails + const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); + if (accountEnvelopes.length > 0 && totalAllocated > 0) { + accountEnvelopes.forEach(envelope => { + const proportion = envelope.allocated / totalAllocated; + const allocatedAmount = transaction.amount * proportion; + if (allocatedAmount > 0) { + allTransactions.push({ + id: crypto.randomUUID(), + envelopeId: envelope.id, + amount: allocatedAmount, + description: `Income allocation - ${envelope.name}`, + date: transaction.date, + accountId: transaction.accountId, + }); + } + }); + } + } else { + // Use custom allocation settings + envelopesWithAllocation.forEach(envelope => { + let allocatedAmount = 0; + if (envelope.incomeAllocationType === 'percentage') { + allocatedAmount = transaction.amount * (envelope.incomeAllocation! / 100); + } else if (envelope.incomeAllocationType === 'fixed') { + allocatedAmount = Math.min(envelope.incomeAllocation!, transaction.amount); + } + + if (allocatedAmount > 0) { + allTransactions.push({ + id: crypto.randomUUID(), + envelopeId: envelope.id, + amount: allocatedAmount, + description: `Income allocation - ${envelope.name}`, + date: transaction.date, + accountId: transaction.accountId, + }); + } + }); + } + } else { + // No specific envelope allocation found, fall back to proportional allocation const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); if (accountEnvelopes.length > 0 && totalAllocated > 0) { accountEnvelopes.forEach(envelope => { @@ -210,53 +271,18 @@ export default function BudgetDashboard() { } }); } - } else { - // Use custom allocation settings - envelopesWithAllocation.forEach(envelope => { - let allocatedAmount = 0; - if (envelope.incomeAllocationType === 'percentage') { - allocatedAmount = transaction.amount * (envelope.incomeAllocation! / 100); - } else if (envelope.incomeAllocationType === 'fixed') { - allocatedAmount = Math.min(envelope.incomeAllocation!, transaction.amount); - } - - if (allocatedAmount > 0) { - allTransactions.push({ - id: crypto.randomUUID(), - envelopeId: envelope.id, - amount: allocatedAmount, - description: `Income allocation - ${envelope.name}`, - date: transaction.date, - accountId: transaction.accountId, - }); - } - }); - } - } else { - // No specific envelope allocation found, fall back to proportional allocation - const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); - if (accountEnvelopes.length > 0 && totalAllocated > 0) { - accountEnvelopes.forEach(envelope => { - const proportion = envelope.allocated / totalAllocated; - const allocatedAmount = transaction.amount * proportion; - if (allocatedAmount > 0) { - allTransactions.push({ - id: crypto.randomUUID(), - envelopeId: envelope.id, - amount: allocatedAmount, - description: `Income allocation - ${envelope.name}`, - date: transaction.date, - accountId: transaction.accountId, - }); - } - }); } } } }); - // Add all processed transactions at once + // Add all processed transactions at once and immediately recompute derived balances setTransactions(prevTransactions => [...prevTransactions, ...allTransactions]); + + // Recalculate envelopes and accounts with the new transactions + const updatedTransactions = [...transactions, ...allTransactions]; + setEnvelopes(calculateEnvelopeSpending(envelopes, updatedTransactions)); + setAccounts(calculateAccountBalances(accounts, updatedTransactions)); }; const validateIncomeAllocation = (envelopes: Envelope[], totalAmount: number) => { @@ -280,9 +306,9 @@ export default function BudgetDashboard() { return { isValid: false, message: 'Conflicting allocation types (fixed and percentage) detected.' }; } - // Validate fixed allocations - if (hasFixed && fixedTotal !== totalAmount) { - return { isValid: false, message: 'Fixed allocations do not sum up to the total amount.' }; + // Validate fixed allocations - allow partial allocation (don't require exact match) + if (hasFixed && fixedTotal > totalAmount) { + return { isValid: false, message: 'Fixed allocations exceed the total amount.' }; } // Validate percentage allocations @@ -308,7 +334,11 @@ export default function BudgetDashboard() { }; const handleSetupComplete = (accounts: Account[], envelopes: Envelope[]) => { - setAccounts(accounts); + const accountsWithBaseline = accounts.map((account) => { + const baseBalance = account.startingBalance ?? account.balance; + return { ...account, startingBalance: baseBalance, balance: baseBalance }; + }); + setAccounts(accountsWithBaseline); setEnvelopes(envelopes); setTransactions([]); setSetupCompleted(true); @@ -316,11 +346,21 @@ export default function BudgetDashboard() { }; const handleAccountUpdate = (updatedAccount: Account) => { - setAccounts(prevAccounts => prevAccounts.map(acc => acc.id === updatedAccount.id ? updatedAccount : acc)); + setAccounts((prevAccounts) => { + const baseBalance = updatedAccount.startingBalance ?? updatedAccount.balance; + const nextAccounts = prevAccounts.map((account) => + account.id === updatedAccount.id ? { ...updatedAccount, startingBalance: baseBalance } : account, + ); + return calculateAccountBalances(nextAccounts, transactions); + }); }; const handleAccountAdd = (newAccount: Account) => { - setAccounts(prevAccounts => [...prevAccounts, newAccount]); + setAccounts((prevAccounts) => { + const baseBalance = newAccount.startingBalance ?? newAccount.balance; + const nextAccounts = [...prevAccounts, { ...newAccount, startingBalance: baseBalance }]; + return calculateAccountBalances(nextAccounts, transactions); + }); }; const handleAccountDelete = (accountId: string) => { @@ -332,11 +372,21 @@ export default function BudgetDashboard() { }; const handleTransactionEdit = (updatedTransaction: Transaction) => { - setTransactions(prevTransactions => prevTransactions.map(tx => tx.id === updatedTransaction.id ? updatedTransaction : tx)); + setTransactions(prevTransactions => { + const newTransactions = prevTransactions.map(tx => tx.id === updatedTransaction.id ? updatedTransaction : tx); + setEnvelopes(calculateEnvelopeSpending(envelopes, newTransactions)); + setAccounts(calculateAccountBalances(accounts, newTransactions)); + return newTransactions; + }); }; const handleTransactionDelete = (transactionId: string) => { - setTransactions(prevTransactions => prevTransactions.filter(tx => tx.id !== transactionId)); + setTransactions(prevTransactions => { + const newTransactions = prevTransactions.filter(tx => tx.id !== transactionId); + setEnvelopes(calculateEnvelopeSpending(envelopes, newTransactions)); + setAccounts(calculateAccountBalances(accounts, newTransactions)); + return newTransactions; + }); }; const handleCreateEnvelope = (newEnvelope: Envelope) => { @@ -352,6 +402,65 @@ export default function BudgetDashboard() { )); }; + const renderLanding = () => ( +
+
+
+ Capsule +

+ Take control of your money with smart envelopes +

+

+ Create envelopes, track spending, and auto-allocate income across accounts. Get set up in minutes with our guided wizard. +

+
+ + +
+
+
+ + Auto-save & offline-ready data +
+
+ + Guided setup wizard +
+
+
+
+
+
+

Total Balance

+

$0.00

+

Link accounts to see your starting balance

+
+
+

Envelopes

+

Organize spending

+

Create categories with color-coded envelopes

+
+
+

Income Auto-Allocation

+

Smart distribution

+

Allocate paychecks by percentage or fixed amounts

+
+
+
+
+
+ ); + return (
{/* Navigation */} @@ -360,7 +469,10 @@ export default function BudgetDashboard() {
-

Envelope Budgeting

+
+

Capsule

+

by NeCloud

+
-
+
+ {user && setupCompleted && ( + + )} {user ? ( @@ -521,7 +656,9 @@ export default function BudgetDashboard() { handleTransactionDelete(transaction.id); } }} - className="text-red-600 hover:text-red-900 text-sm" + className="text-red-600 hover:text-red-900 text-sm font-medium transition-colors" + aria-label={`Delete transaction: ${transaction.description}`} + title={`Delete transaction: ${transaction.description}`} > Delete @@ -547,7 +684,20 @@ export default function BudgetDashboard() { {currentView === 'transactions' && (

Transactions

+ + {/* Add Transactions Form */} + ({ id: acc.id, name: acc.name }))} + transactions={transactions} + /> + + {/* All Transactions List */}
+
+

All Transactions

+
    {transactions.map((transaction) => (
  • @@ -565,7 +715,9 @@ export default function BudgetDashboard() {

    @@ -593,13 +745,15 @@ export default function BudgetDashboard() {

    Envelopes

    {envelopes.map((envelope) => ( -
    +

    {envelope.name}

    @@ -611,18 +765,30 @@ export default function BudgetDashboard() {
    -
    - Allocated - ${envelope.allocated.toFixed(2)} -
    -
    - Spent - ${envelope.spent.toFixed(2)} -
    -
    - Remaining - ${(envelope.allocated - envelope.spent).toFixed(2)} -
    + {(() => { + // Calculate income allocated to this envelope + const incomeAllocated = transactions + .filter(t => t.envelopeId === envelope.id && t.amount > 0) + .reduce((sum, t) => sum + t.amount, 0); + const totalAllocated = envelope.allocated + incomeAllocated; + const remaining = totalAllocated - envelope.spent; + return ( + <> +
    + Allocated + ${totalAllocated.toFixed(2)} +
    +
    + Spent + ${envelope.spent.toFixed(2)} +
    +
    + Remaining + ${remaining.toFixed(2)} +
    + + ); + })()}
    @@ -638,14 +804,14 @@ export default function BudgetDashboard() {
    )} - {currentView === 'settings' && ( -
    -

    Settings

    - -
    + {currentView === 'settings' && user && setupCompleted && ( + { + setCurrentView('dashboard'); + setUser(null); + }} + /> )} )} @@ -654,6 +820,22 @@ export default function BudgetDashboard() { {/* Modals and Components */} {showAuthModal && setShowAuthModal(false)} onAuthSuccess={handleAuthSuccess} />} {showSetupWizard && setShowSetupWizard(false)} userId={user?.userId || ''} />} + {showGetPaidModal && ( +
    +
    e.stopPropagation()} className="w-full"> + { + handleTransactionsAdded(txns); + setShowGetPaidModal(false); + }} + onAccountUpdate={handleAccountUpdate} + autoOpen={true} + /> +
    +
    + )} {editingTransaction && ( ({ id: acc.id, name: acc.name }))} /> )} - - {/* Components visible after setup completion */} - {setupCompleted && ( - <> - ({ id: env.id, name: env.name }))} - accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))} - /> - - - )}
); } diff --git a/src/components/AccountManagement.tsx b/src/components/AccountManagement.tsx index 48c3d0e..39ea761 100644 --- a/src/components/AccountManagement.tsx +++ b/src/components/AccountManagement.tsx @@ -153,9 +153,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd ) : (
{(accounts || []).map((account) => ( -
+
-
+

{account.name}

@@ -176,8 +176,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd

@@ -91,7 +91,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP type="password" value={password} onChange={(e) => setPassword(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" required minLength={6} /> @@ -106,7 +106,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" required minLength={6} /> diff --git a/src/components/DashboardCharts.tsx b/src/components/DashboardCharts.tsx new file mode 100644 index 0000000..6fa6083 --- /dev/null +++ b/src/components/DashboardCharts.tsx @@ -0,0 +1,177 @@ +'use client'; + +interface Account { + id: string; + name: string; + balance: number; + color: string; +} + +interface Envelope { + id: string; + name: string; + allocated: number; + spent: number; + color: string; +} + +interface Transaction { + id: string; + amount: number; + date: Date; + envelopeId?: string; +} + +interface DashboardChartsProps { + accounts: Account[]; + envelopes: Envelope[]; + transactions: Transaction[]; +} + +export default function DashboardCharts({ accounts, envelopes, transactions }: DashboardChartsProps) { + // Calculate spending by envelope + const envelopeSpending = envelopes.map(env => ({ + name: env.name, + spent: Math.abs(env.spent), + allocated: env.allocated, + color: env.color, + percentage: env.allocated > 0 ? (Math.abs(env.spent) / env.allocated) * 100 : 0 + })).sort((a, b) => b.spent - a.spent); + + // Calculate total balance across accounts + const totalBalance = accounts.reduce((sum, acc) => sum + acc.balance, 0); + const accountBalances = accounts.map(acc => ({ + name: acc.name, + balance: acc.balance, + color: acc.color, + percentage: totalBalance > 0 ? (acc.balance / totalBalance) * 100 : 0 + })); + + // Calculate spending over time (last 30 days) + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const recentTransactions = transactions.filter(t => { + const txDate = t.date instanceof Date ? t.date : new Date(t.date); + return txDate >= thirtyDaysAgo; + }); + + const dailySpending: { [key: string]: number } = {}; + recentTransactions.forEach(t => { + const txDate = t.date instanceof Date ? t.date : new Date(t.date); + const dateKey = txDate.toISOString().split('T')[0]; + if (t.amount < 0) { + dailySpending[dateKey] = (dailySpending[dateKey] || 0) + Math.abs(t.amount); + } + }); + + const spendingData = Object.entries(dailySpending) + .sort((a, b) => a[0].localeCompare(b[0])) + .slice(-7); // Last 7 days + + const maxDailySpending = Math.max(...spendingData.map(([_, amount]) => amount), 1); + + return ( +
+ {/* Envelope Spending Chart */} +
+

Envelope Spending

+ {envelopeSpending.length > 0 ? ( +
+ {envelopeSpending.slice(0, 5).map((env) => ( +
+
+ {env.name} + + ${env.spent.toFixed(2)} / ${env.allocated.toFixed(2)} + +
+
+
100 ? 'bg-red-500' : '' + }`} + style={{ width: `${Math.min(env.percentage, 100)}%` }} + >
+
+ {env.percentage > 100 && ( +

Over budget by ${(env.spent - env.allocated).toFixed(2)}

+ )} +
+ ))} +
+ ) : ( +

No envelope spending data yet.

+ )} +
+ + {/* Account Balances Chart */} +
+

Account Balances

+ {accountBalances.length > 0 ? ( +
+ {accountBalances.map((acc) => ( +
+
+ {acc.name} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + ${acc.balance.toFixed(2)} + +
+
+
+
+
+ ))} +
+
+ Total Balance + = 0 ? 'text-green-600' : 'text-red-600'}`}> + ${totalBalance.toFixed(2)} + +
+
+
+ ) : ( +

No account data yet.

+ )} +
+ + {/* Daily Spending Chart (Last 7 Days) */} +
+

Daily Spending (Last 7 Days)

+ {spendingData.length > 0 ? ( +
+ {spendingData.map(([date, amount]) => { + const height = (amount / maxDailySpending) * 100; + const dateObj = new Date(date); + const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' }); + + return ( +
+
+
+
+ ${amount.toFixed(2)} +
+
+
+
{dayName}
+
{dateObj.getDate()}
+
+ ); + })} +
+ ) : ( +

No spending data for the last 7 days.

+ )} +
+
+ ); +} diff --git a/src/components/DataInput.tsx b/src/components/DataInput.tsx index db56067..4fa2a32 100644 --- a/src/components/DataInput.tsx +++ b/src/components/DataInput.tsx @@ -4,6 +4,19 @@ import { useState } from 'react'; import FileUpload, { ParsedTransaction } from './FileUpload'; import ManualEntry from './ManualEntry'; +interface Envelope { + id: string; + name: string; + allocated?: number; + spent?: number; +} + +interface Transaction { + id: string; + envelopeId?: string; + amount: number; +} + interface DataInputProps { onTransactionsAdded: (transactions: Array<{ id: string; @@ -13,17 +26,18 @@ interface DataInputProps { date: Date; accountId: string; }>) => void; - envelopes: { id: string; name: string }[]; + envelopes: Envelope[]; accounts: { id: string; name: string }[]; + transactions?: Transaction[]; } type InputMode = 'file' | 'manual'; -export default function DataInput({ onTransactionsAdded, envelopes, accounts }: DataInputProps) { +export default function DataInput({ onTransactionsAdded, envelopes, accounts, transactions = [] }: DataInputProps) { const [inputMode, setInputMode] = useState('manual'); const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => { - const transactions = parsedTransactions.map((t) => ({ + const txns = parsedTransactions.map((t) => ({ id: crypto.randomUUID(), amount: t.amount, description: t.description, @@ -32,7 +46,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }: accountId: accounts?.[0]?.id || 'default-account', // Default to first account })); - onTransactionsAdded(transactions); + onTransactionsAdded(txns); }; const handleManualTransactionAdded = (transaction: { @@ -86,6 +100,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }: onTransactionAdded={handleManualTransactionAdded} envelopes={envelopes} accounts={accounts} + transactions={transactions} /> ) : ( diff --git a/src/components/DemoWizard.tsx b/src/components/DemoWizard.tsx new file mode 100644 index 0000000..e608387 --- /dev/null +++ b/src/components/DemoWizard.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface Account { + id: string; + name: string; + type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan'; + balance: number; + color: string; + isActive: boolean; +} + +interface Envelope { + id: string; + name: string; + allocated: number; + spent: number; + color: string; + accountId: string; + isRecurring?: boolean; + recurringFrequency?: 'weekly' | 'monthly' | 'yearly'; + lastRecurringDate?: string; +} + +interface DemoWizardProps { + onApplyDemo: (accounts: Account[], envelopes: Envelope[]) => void; + onSkip: () => void; + accountId?: string; +} + +const DEMO_DATA = { + accounts: [ + { + id: crypto.randomUUID(), + name: 'Main Checking', + type: 'checking' as const, + balance: 5000, + color: 'bg-blue-500', + isActive: true, + }, + { + id: crypto.randomUUID(), + name: 'Emergency Fund', + type: 'savings' as const, + balance: 10000, + color: 'bg-green-500', + isActive: true, + }, + ], + envelopes: [ + { name: 'Rent', allocated: 1500, color: 'bg-red-500', isRecurring: true, recurringFrequency: 'monthly' as const }, + { name: 'Groceries', allocated: 400, color: 'bg-green-500', isRecurring: true, recurringFrequency: 'monthly' as const }, + { name: 'Utilities', allocated: 150, color: 'bg-yellow-500', isRecurring: true, recurringFrequency: 'monthly' as const }, + { name: 'Entertainment', allocated: 200, color: 'bg-purple-500', isRecurring: false }, + { name: 'Dining Out', allocated: 250, color: 'bg-pink-500', isRecurring: false }, + { name: 'Car Payment', allocated: 350, color: 'bg-indigo-500', isRecurring: true, recurringFrequency: 'monthly' as const }, + ], +}; + +export default function DemoWizard({ onApplyDemo, onSkip, accountId }: DemoWizardProps) { + const [showDemo, setShowDemo] = useState(false); + + const handleApplyDemo = () => { + // Use first account as default, or create new ones + const firstAccountId = accountId || DEMO_DATA.accounts[0].id; + + const envelopes = DEMO_DATA.envelopes.map((env) => ({ + id: crypto.randomUUID(), + ...env, + accountId: firstAccountId, + spent: 0, + })); + + onApplyDemo(DEMO_DATA.accounts, envelopes); + }; + + if (!showDemo) { + return ( +
+
📊
+

Want to See a Demo?

+

+ We can populate your budget with sample accounts and envelopes to help you get started and explore the app's features. +

+
+ + +
+
+ ); + } + + return ( +
+
+

Demo Budget Setup

+

+ Here's what we'll create for you: +

+
+ +
+
+

🏦 Accounts

+
    + {DEMO_DATA.accounts.map((acc) => ( +
  • + {acc.name} - ${acc.balance.toLocaleString()} +
  • + ))} +
+
+ +
+

📦 Envelopes

+
    + {DEMO_DATA.envelopes.map((env) => ( +
  • + {env.name} - ${env.allocated}/mo + {env.isRecurring && ( + Recurring + )} +
  • + ))} +
+
+
+ +
+

+ 💡 Tip: You can always edit or delete these envelopes and accounts later. This is just a starting point! +

+
+ +
+ + +
+
+ ); +} diff --git a/src/components/EnvelopeEdit.tsx b/src/components/EnvelopeEdit.tsx index ca21b85..95ed9e9 100644 --- a/src/components/EnvelopeEdit.tsx +++ b/src/components/EnvelopeEdit.tsx @@ -47,6 +47,16 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage'); const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || ''); + // Sync form fields when a different envelope is opened for editing + useEffect(() => { + setName(envelope.name); + setAllocated(envelope.allocated.toString()); + setSelectedColor(envelope.color); + setSelectedAccount(envelope.accountId); + setIncomeAllocationType(envelope.incomeAllocationType || 'percentage'); + setIncomeAllocation(envelope.incomeAllocation?.toString() || ''); + }, [envelope]); + const handleDelete = () => { if (confirm(`Are you sure you want to delete the envelope "${envelope.name}"? Transactions linked to this envelope will be unassigned.`)) { if (onEnvelopeDeleted) { diff --git a/src/components/GetPaid.tsx b/src/components/GetPaid.tsx index df290ba..708c042 100644 --- a/src/components/GetPaid.tsx +++ b/src/components/GetPaid.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; interface Account { id: string; @@ -32,69 +32,106 @@ interface Transaction { accountId: string; } +type DistributionMethod = 'proportional' | 'equal' | 'custom'; +type AllocationMap = Record; + +interface PaycheckTemplate { + amount: number; + selectedAccountId: string; + description: string; + distributionMethod: DistributionMethod; + customAllocations: AllocationMap; +} + interface GetPaidProps { accounts: Account[]; envelopes: Envelope[]; onIncomeAdded: (transactions: Transaction[]) => void; onAccountUpdate?: (account: Account) => void; + autoOpen?: boolean; } -export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountUpdate }: GetPaidProps) { - const [isOpen, setIsOpen] = useState(false); +export default function GetPaid({ + accounts, + envelopes, + onIncomeAdded, + onAccountUpdate, + autoOpen = false, +}: GetPaidProps) { + const [isOpen, setIsOpen] = useState(autoOpen); + const [showPreview, setShowPreview] = useState(false); const [amount, setAmount] = useState(''); const [selectedAccountId, setSelectedAccountId] = useState(''); const [description, setDescription] = useState('Paycheck'); - const [distributionMethod, setDistributionMethod] = useState<'proportional' | 'equal' | 'custom'>('proportional'); - const [customAllocations, setCustomAllocations] = useState<{ [envelopeId: string]: number }>({}); + const [distributionMethod, setDistributionMethod] = useState('proportional'); + const [customAllocations, setCustomAllocations] = useState({}); + const [previewAllocations, setPreviewAllocations] = useState({}); const [showDefaultAmountField, setShowDefaultAmountField] = useState(false); const [defaultAmountInput, setDefaultAmountInput] = useState(''); + const [saveTemplate, setSaveTemplate] = useState(false); + const [savedTemplate, setSavedTemplate] = useState(null); - if (!accounts || accounts.length === 0) { - return ( -
-

Get Paid

-

Please add an account first to record paychecks.

-
- ); - } + useEffect(() => { + if (typeof window === 'undefined') return; + const template = localStorage.getItem('paycheckTemplate'); + if (!template) return; - if (!envelopes) { - return ( -
-

Get Paid

-

Loading...

-
- ); - } + try { + const parsed = JSON.parse(template) as PaycheckTemplate; + setSavedTemplate(parsed); + } catch (error) { + console.error('Error loading saved template', error); + } + }, []); + + const accountEnvelopes = selectedAccountId + ? envelopes.filter((env) => env.accountId === selectedAccountId) + : []; - const accountEnvelopes = selectedAccountId ? envelopes.filter(env => env.accountId === selectedAccountId) : []; const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); - const calculateDistribution = () => { + const calculateDistribution = (): AllocationMap => { const paycheckAmount = parseFloat(amount); if (!paycheckAmount || accountEnvelopes.length === 0) return {}; - const distribution: { [envelopeId: string]: number } = {}; + if (distributionMethod === 'custom') { + const sanitized: AllocationMap = {}; + accountEnvelopes.forEach((env) => { + sanitized[env.id] = Math.max(0, customAllocations[env.id] || 0); + }); + return sanitized; + } + + const distribution: AllocationMap = {}; if (distributionMethod === 'equal') { const equalAmount = paycheckAmount / accountEnvelopes.length; - accountEnvelopes.forEach(env => { + accountEnvelopes.forEach((env) => { distribution[env.id] = equalAmount; }); - } else if (distributionMethod === 'proportional') { - accountEnvelopes.forEach(env => { - const proportion = env.allocated / totalAllocated; - distribution[env.id] = paycheckAmount * proportion; - }); - } else if (distributionMethod === 'custom') { - return customAllocations; + return distribution; } + accountEnvelopes.forEach((env) => { + const proportion = totalAllocated > 0 ? env.allocated / totalAllocated : 0; + distribution[env.id] = paycheckAmount * proportion; + }); + return distribution; }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const applySavedTemplate = () => { + if (!savedTemplate) return; + + setAmount(savedTemplate.amount?.toString() || ''); + setSelectedAccountId(savedTemplate.selectedAccountId || ''); + setDescription(savedTemplate.description || 'Paycheck'); + setDistributionMethod(savedTemplate.distributionMethod || 'proportional'); + setCustomAllocations(savedTemplate.customAllocations || {}); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); const paycheckAmount = parseFloat(amount); if (!paycheckAmount || !selectedAccountId) { @@ -102,10 +139,22 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU return; } + if (paycheckAmount <= 0 || paycheckAmount > 999999999) { + alert('Please enter a valid paycheck amount ($0 - $999,999,999)'); + return; + } + const distribution = calculateDistribution(); + setPreviewAllocations(distribution); + setShowPreview(true); + }; + + const handleConfirmPaycheck = () => { + const paycheckAmount = parseFloat(amount); + if (!paycheckAmount || !selectedAccountId) return; + const transactions: Transaction[] = []; - // Add the main paycheck transaction to the account transactions.push({ id: crypto.randomUUID(), amount: paycheckAmount, @@ -114,27 +163,53 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU accountId: selectedAccountId, }); - // Add allocation transactions to envelopes - Object.entries(distribution).forEach(([envelopeId, allocatedAmount]) => { + Object.entries(previewAllocations).forEach(([envelopeId, allocatedAmount]) => { if (allocatedAmount > 0) { transactions.push({ id: crypto.randomUUID(), envelopeId, amount: allocatedAmount, - description: `Paycheck allocation - ${envelopes.find(e => e.id === envelopeId)?.name}`, + description: `${description} → ${envelopes.find((env) => env.id === envelopeId)?.name || 'Envelope'}`, date: new Date(), accountId: selectedAccountId, }); } }); + if (saveTemplate && typeof window !== 'undefined') { + const template: PaycheckTemplate = { + amount: paycheckAmount, + selectedAccountId, + description, + distributionMethod, + customAllocations: previewAllocations, + }; + localStorage.setItem('paycheckTemplate', JSON.stringify(template)); + setSavedTemplate(template); + } + onIncomeAdded(transactions); + setIsOpen(false); + setShowPreview(false); setAmount(''); + setSelectedAccountId(''); setDescription('Paycheck'); setCustomAllocations({}); + setPreviewAllocations({}); setShowDefaultAmountField(false); setDefaultAmountInput(''); + setSaveTemplate(false); + }; + + const handleEditAllocation = (envelopeId: string, newAmount: string) => { + const numAmount = parseFloat(newAmount); + if (Number.isNaN(numAmount) || numAmount < 0) return; + + setPreviewAllocations((prev) => ({ + ...prev, + [envelopeId]: numAmount, + })); }; const handleSetDefaultAmount = () => { @@ -144,13 +219,10 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU return; } - const selectedAccount = accounts.find(acc => acc.id === selectedAccountId); + const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); if (!selectedAccount) return; - const updatedAccount = { - ...selectedAccount, - defaultPaycheckAmount: defaultAmount - }; + const updatedAccount = { ...selectedAccount, defaultPaycheckAmount: defaultAmount }; if (onAccountUpdate) { onAccountUpdate(updatedAccount); @@ -163,35 +235,62 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU }; const handleUseDefaultAmount = () => { - const selectedAccount = accounts.find(acc => acc.id === selectedAccountId); + const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); if (selectedAccount?.defaultPaycheckAmount) { setAmount(selectedAccount.defaultPaycheckAmount.toString()); } }; const distribution = calculateDistribution(); - const totalDistributed = Object.values(distribution).reduce((sum, amt) => sum + amt, 0); - const remaining = parseFloat(amount) - totalDistributed; + const numericAmount = parseFloat(amount || '0') || 0; + const totalDistributed = Object.values(distribution).reduce((sum, value) => sum + value, 0); + const remaining = numericAmount - totalDistributed; return ( <> - + {!autoOpen && ( + + )} {isOpen && (

Record Paycheck

+ {savedTemplate && ( +
+
+
+

Saved Template Available

+

+ Amount: ${savedTemplate.amount?.toFixed(2)} • {savedTemplate.description} +

+
+ +
+
+ )} +
- {/* Basic Info */}
@@ -201,21 +300,25 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU type="number" step="0.01" value={amount} - onChange={(e) => setAmount(e.target.value)} - className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + onChange={(event) => setAmount(event.target.value)} + className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400" placeholder="0.00" required />
- {selectedAccountId && accounts.find(acc => acc.id === selectedAccountId)?.defaultPaycheckAmount && ( - - )} + {selectedAccountId && + accounts.find((acc) => acc.id === selectedAccountId)?.defaultPaycheckAmount && ( + + )} {selectedAccountId && (
@@ -253,16 +356,23 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
@@ -272,58 +382,71 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU setDescription(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + onChange={(event) => setDescription(event.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400" placeholder="Paycheck description" />
- {/* Distribution Method */} +
+ setSaveTemplate(event.target.checked)} + className="mr-2" + /> + +
+ {selectedAccountId && accountEnvelopes.length > 0 && ( <>
- -
-
- {/* Distribution Preview */}

Income Distribution Preview

{accountEnvelopes.map((envelope) => { const allocatedAmount = distribution[envelope.id] || 0; - const percentage = parseFloat(amount) > 0 ? (allocatedAmount / parseFloat(amount)) * 100 : 0; + const percentage = numericAmount > 0 ? (allocatedAmount / numericAmount) * 100 : 0; return (
@@ -336,18 +459,20 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU setCustomAllocations(prev => ({ - ...prev, - [envelope.id]: parseFloat(e.target.value) || 0 - }))} - className="w-20 px-2 py-1 text-sm border border-gray-300 rounded" + value={customAllocations[envelope.id] ?? ''} + onChange={(event) => + setCustomAllocations((prev) => ({ + ...prev, + [envelope.id]: parseFloat(event.target.value) || 0, + })) + } + className="w-20 px-2 py-1 text-sm border border-gray-300 rounded bg-white text-gray-900 placeholder-gray-400" placeholder="0.00" /> ) : ( - ${allocatedAmount.toFixed(2)} + ${allocatedAmount.toFixed(2)} )} - ({percentage.toFixed(1)}%) + ({percentage.toFixed(1)}%)
); @@ -374,7 +499,6 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU )} - {/* Actions */}
)} + + {showPreview && ( +
+
+

Confirm Paycheck Allocation

+ +
+
+
+ Paycheck Amount: + ${numericAmount.toFixed(2)} +
+
+ Deposit to: + + {accounts.find((acc) => acc.id === selectedAccountId)?.name} + +
+ {description && ( +
+ Description: + {description} +
+ )} +
+
+ +

Envelope Allocations

+

Review and adjust how this paycheck will be distributed:

+ +
+ {accountEnvelopes.map((envelope) => { + const allocatedAmount = previewAllocations[envelope.id] || 0; + const percentage = numericAmount > 0 ? (allocatedAmount / numericAmount) * 100 : 0; + + return ( +
+
+
+
+ {envelope.name} +
+ {percentage.toFixed(1)}% of paycheck +
+
+ Amount: +
+ $ + handleEditAllocation(envelope.id, event.target.value)} + className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" + /> +
+
+
+ ); + })} +
+ +
+
+ Total Allocated: + + ${Object.values(previewAllocations).reduce((sum, value) => sum + value, 0).toFixed(2)} + +
+ {(() => { + const totalAllocatedPreview = Object.values(previewAllocations).reduce( + (sum, value) => sum + value, + 0, + ); + const remainingAmount = numericAmount - totalAllocatedPreview; + + if (Math.abs(remainingAmount) >= 0.01) { + return ( +
+ Unallocated: + = 0 ? 'text-orange-600' : 'text-red-600'}`} + > + ${remainingAmount.toFixed(2)} {remainingAmount < 0 && '(over allocated!)'} + +
+ ); + } + + return null; + })()} +
+ +
+ + +
+
+
+ )} ); -} \ No newline at end of file +} + diff --git a/src/components/ManualEntry.tsx b/src/components/ManualEntry.tsx index fc90e69..e4cb52d 100644 --- a/src/components/ManualEntry.tsx +++ b/src/components/ManualEntry.tsx @@ -2,6 +2,19 @@ import { useState, useEffect } from 'react'; +interface Envelope { + id: string; + name: string; + allocated?: number; + spent?: number; +} + +interface Transaction { + id: string; + envelopeId?: string; + amount: number; +} + interface ManualEntryProps { onTransactionAdded: (transaction: { amount: number; @@ -10,11 +23,12 @@ interface ManualEntryProps { envelopeId?: string; accountId: string; }) => void; - envelopes: { id: string; name: string }[]; + envelopes: Envelope[]; accounts: { id: string; name: string }[]; + transactions?: Transaction[]; } -export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: ManualEntryProps) { +export default function ManualEntry({ onTransactionAdded, envelopes, accounts, transactions = [] }: ManualEntryProps) { const [amount, setAmount] = useState(''); const [description, setDescription] = useState(''); const [date, setDate] = useState(new Date().toISOString().split('T')[0]); @@ -33,8 +47,36 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: e.preventDefault(); const numAmount = parseFloat(amount); - if (isNaN(numAmount) || !description.trim()) { - alert('Please enter a valid amount and description'); + const descriptionTrimmed = description.trim(); + + // Validation + if (isNaN(numAmount)) { + alert('Please enter a valid amount'); + return; + } + + if (numAmount <= 0) { + alert('Amount must be greater than 0'); + return; + } + + if (numAmount > 999999999) { + alert('Amount is too large (max: $999,999,999)'); + return; + } + + if (!descriptionTrimmed) { + alert('Please enter a description'); + return; + } + + if (descriptionTrimmed.length < 3) { + alert('Description must be at least 3 characters'); + return; + } + + if (descriptionTrimmed.length > 100) { + alert('Description cannot exceed 100 characters'); return; } @@ -42,7 +84,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: onTransactionAdded({ amount: transactionAmount, - description: description.trim(), + description: descriptionTrimmed, date: new Date(date), envelopeId: selectedEnvelope || undefined, accountId: selectedAccount, @@ -92,7 +134,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" - className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400" required />
@@ -106,7 +148,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: type="date" value={date} onChange={(e) => setDate(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" required />
@@ -121,7 +163,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Enter transaction description" - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400" required />
@@ -133,14 +175,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
@@ -151,7 +202,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
-
-
@@ -289,7 +388,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP {/* Existing Accounts */}
-

Your Accounts

+

Your Accounts

{(accounts || []).length === 0 ? (

No accounts added yet

) : ( @@ -299,11 +398,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
-

{account.name}

+

{account.name}

{account.type.replace('_', ' ').toUpperCase()}

-

${account.balance.toFixed(2)}

+

${account.balance.toFixed(2)}

))}
@@ -343,27 +442,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP {/* Add Account Modal */} {showAddAccount && (
-
-

Add Account

+
+

Add Account

- + setNewAccount(prev => ({ ...prev, name: e.target.value }))} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" placeholder="e.g., Main Checking" />
- + setNewAccount(prev => ({ ...prev, balance: parseFloat(e.target.value) || 0 }))} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" placeholder="0.00" />
- + setNewAccount(prev => ({ ...prev, institution: e.target.value }))} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" placeholder="e.g., Bank of America" />
- +
{envelopeColors.slice(0, 6).map((color) => (
); })} @@ -473,7 +572,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP checked={includeStandardEnvelopes} onChange={(e) => setIncludeStandardEnvelopes(e.target.checked)} /> - Add standard envelopes + Add standard envelopes
@@ -551,27 +650,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP {/* Add Envelope Modal */} {showAddEnvelope && (
-
-

Add Envelope

+
+

Add Envelope

- + setNewEnvelope(prev => ({ ...prev, name: e.target.value }))} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" placeholder="e.g., Groceries" />
- + setNewEnvelope(prev => ({ ...prev, allocated: parseFloat(e.target.value) || 0 }))} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" placeholder="0.00" />
- +
{envelopeColors.map((color) => ( +
e.stopPropagation()}> + {/* Close button - only on welcome step, or if closing from welcome, create demo data */} + {currentStep === 'welcome' && ( + + )} {/* Progress Indicator */}
@@ -940,7 +1043,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
))}
-
+
{currentStep === 'welcome' && 'Welcome'} {currentStep === 'data-source' && 'Choose Setup Method'} {currentStep === 'accounts' && 'Add Your Accounts'} diff --git a/src/components/SharedAccountManager.tsx b/src/components/SharedAccountManager.tsx new file mode 100644 index 0000000..d52f5a8 --- /dev/null +++ b/src/components/SharedAccountManager.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState } from 'react'; + +interface SharedAccountManagerProps { + userId: string; + accountId: string; + accountName: string; +} + +export default function SharedAccountManager({ userId, accountId, accountName }: SharedAccountManagerProps) { + const [expanded, setExpanded] = useState(false); + const [shareEmail, setShareEmail] = useState(''); + const [shareRole, setShareRole] = useState<'view' | 'edit'>('view'); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [sharedUsers, setSharedUsers] = useState>([]); + const [loading, setLoading] = useState(false); + + const handleShareAccount = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(null); + setLoading(true); + + try { + const response = await fetch('/api/share/account', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + body: JSON.stringify({ accountId, email: shareEmail, role: shareRole }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to share account'); + } + + setMessage({ type: 'success', text: `Account shared with ${shareEmail}` }); + setShareEmail(''); + setSharedUsers([...sharedUsers, { email: shareEmail, role: shareRole }]); + } catch (error) { + setMessage({ + type: 'error', + text: error instanceof Error ? error.message : 'Failed to share account', + }); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + {expanded && ( +
+
+
+ + setShareEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" + placeholder="user@example.com" + /> +
+ +
+ + +
+ + +
+ + {message && ( +
+ {message.text} +
+ )} + + {sharedUsers.length > 0 && ( +
+

Shared With:

+
    + {sharedUsers.map((user) => ( +
  • + {user.email} + + {user.role} + +
  • + ))} +
+
+ )} + +

+ 💡 Note: Shared accounts will appear in the recipient's Capsule app. They can view or edit based on the permissions you set. +

+
+ )} +
+ ); +} diff --git a/src/components/TransactionEdit.tsx b/src/components/TransactionEdit.tsx index 54a293e..1d59a02 100644 --- a/src/components/TransactionEdit.tsx +++ b/src/components/TransactionEdit.tsx @@ -134,24 +134,24 @@ export default function TransactionEdit({ transaction, envelopes, accounts, onSa
-
-