Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover

This commit is contained in:
nmemmert committed 2026-01-13 13:38:27 -05:00
1 parent caa6020ae8
commit 1b269dedd9
24 files changed
+2117 -324

No files matched your search

+48 -60
View File
@@ -8,7 +8,7 @@
"name": "budget", "name": "budget",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.0.0", "next": "16.1.1",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0"
}, },
@@ -18,6 +18,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.0", "eslint-config-next": "16.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -68,7 +69,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -999,9 +999,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==", "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -1015,9 +1015,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==", "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1031,9 +1031,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==", "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1047,9 +1047,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==", "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1063,9 +1063,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==", "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1079,9 +1079,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==", "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1095,9 +1095,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==", "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1111,9 +1111,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==", "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1127,9 +1127,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==", "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1525,7 +1525,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -1586,7 +1585,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@@ -2117,7 +2115,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2404,7 +2401,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/types": "^7.26.0" "@babel/types": "^7.26.0"
} }
@@ -2417,10 +2413,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.9.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
@@ -2470,7 +2465,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -3038,7 +3032,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3224,7 +3217,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -4428,9 +4420,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4970,13 +4962,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.0", "version": "16.1.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==", "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.0.0", "@next/env": "16.1.1",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.6" "styled-jsx": "5.1.6"
@@ -4988,14 +4981,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.0", "@next/swc-darwin-arm64": "16.1.1",
"@next/swc-darwin-x64": "16.0.0", "@next/swc-darwin-x64": "16.1.1",
"@next/swc-linux-arm64-gnu": "16.0.0", "@next/swc-linux-arm64-gnu": "16.1.1",
"@next/swc-linux-arm64-musl": "16.0.0", "@next/swc-linux-arm64-musl": "16.1.1",
"@next/swc-linux-x64-gnu": "16.0.0", "@next/swc-linux-x64-gnu": "16.1.1",
"@next/swc-linux-x64-musl": "16.0.0", "@next/swc-linux-x64-musl": "16.1.1",
"@next/swc-win32-arm64-msvc": "16.0.0", "@next/swc-win32-arm64-msvc": "16.1.1",
"@next/swc-win32-x64-msvc": "16.0.0", "@next/swc-win32-x64-msvc": "16.1.1",
"sharp": "^0.34.4" "sharp": "^0.34.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -5403,7 +5396,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5413,7 +5405,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6100,7 +6091,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6263,7 +6253,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6539,7 +6528,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+2 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"next": "16.0.0", "next": "16.1.1",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0"
}, },
@@ -19,6 +19,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.0", "eslint-config-next": "16.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
+25
View File
@@ -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 }
);
}
}
+34
View File
@@ -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 }
);
}
}
+32
View File
@@ -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 }
);
}
}
+14
View File
@@ -24,3 +24,17 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; 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;
}
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { 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", description: "A modern envelope budgeting application to help you manage your finances effectively",
}; };
+232 -67
View File
@@ -11,6 +11,8 @@ import DataExport from '../components/DataExport';
import AuthModal from '../components/AuthModal'; import AuthModal from '../components/AuthModal';
import SetupWizard from '../components/SetupWizard'; import SetupWizard from '../components/SetupWizard';
import AccountManagement from '../components/AccountManagement'; import AccountManagement from '../components/AccountManagement';
import DashboardCharts from '../components/DashboardCharts';
import Settings from '../components/Settings';
import GetPaid from '../components/GetPaid'; import GetPaid from '../components/GetPaid';
interface User { interface User {
@@ -23,6 +25,7 @@ interface Account {
name: string; name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan'; type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number; balance: number;
startingBalance?: number;
institution?: string; institution?: string;
accountNumber?: string; accountNumber?: string;
color: string; color: string;
@@ -58,6 +61,7 @@ export default function BudgetDashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAuthModal, setShowAuthModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false);
const [showSetupWizard, setShowSetupWizard] = useState(false); const [showSetupWizard, setShowSetupWizard] = useState(false);
const [showGetPaidModal, setShowGetPaidModal] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [envelopes, setEnvelopes] = useState<Envelope[]>([]); const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
@@ -81,18 +85,26 @@ export default function BudgetDashboard() {
try { try {
const userData = await DataService.loadUserData(); const userData = await DataService.loadUserData();
if (userData) { if (userData) {
setAccounts(userData.accounts || []);
setEnvelopes(userData.envelopes || []);
// Convert date strings to Date objects // Convert date strings to Date objects
const transactionsWithDates = (userData.transactions || []).map(t => ({ const transactionsWithDates = (userData.transactions || []).map((transaction) => ({
...t, ...transaction,
date: typeof t.date === 'string' ? new Date(t.date) : t.date 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); setTransactions(transactionsWithDates);
setSetupCompleted(userData.setupCompleted || false); setSetupCompleted(userData.setupCompleted || false);
// Show setup wizard if setup is not completed OR if there are no accounts // 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); setShowSetupWizard(shouldShowWizard);
} else { } else {
// New user - show setup wizard // New user - show setup wizard
@@ -118,10 +130,10 @@ export default function BudgetDashboard() {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// Save data whenever data changes // Save data whenever data changes (with debouncing to prevent race conditions)
useEffect(() => { useEffect(() => {
if (user && (((accounts || []).length > 0 || (envelopes || []).length > 0 || (transactions || []).length > 0 || setupCompleted))) { if (user && (((accounts || []).length > 0 || (envelopes || []).length > 0 || (transactions || []).length > 0 || setupCompleted))) {
const saveData = async () => { const debounceTimer = setTimeout(async () => {
try { try {
// Recalculate envelope spent amounts before saving // Recalculate envelope spent amounts before saving
const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions); const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions);
@@ -137,8 +149,9 @@ export default function BudgetDashboard() {
} catch (error) { } catch (error) {
console.error('Error saving user data:', error); console.error('Error saving user data:', error);
} }
}; }, 1000); // Debounce saves by 1 second
saveData();
return () => clearTimeout(debounceTimer);
} }
}, [user, accounts, envelopes, transactions, setupCompleted]); }, [user, accounts, envelopes, transactions, setupCompleted]);
@@ -147,33 +160,30 @@ export default function BudgetDashboard() {
return envelopes.map(envelope => { return envelopes.map(envelope => {
const envelopeTransactions = transactions.filter(t => t.envelopeId === envelope.id); const envelopeTransactions = transactions.filter(t => t.envelopeId === envelope.id);
const spent = envelopeTransactions.reduce((sum, t) => { const spent = envelopeTransactions.reduce((sum, t) => {
// For envelopes, we track spending as positive amounts // Track spending from negative amounts (expenses)
// Negative transaction amounts (expenses) increase spending // Positive amounts are income allocations to the envelope
return sum + Math.abs(t.amount < 0 ? t.amount : 0); return sum + (t.amount < 0 ? Math.abs(t.amount) : 0);
}, 0); }, 0);
// Keep allocated as-is; income allocations are stored in transactions, not in allocated
return { ...envelope, spent }; return { ...envelope, spent };
}); });
}; };
// Calculate account balances from transactions // Calculate account balances from transactions
const calculateAccountBalances = (accounts: Account[], transactions: Transaction[]): Account[] => { const calculateAccountBalances = (accounts: Account[], transactions: Transaction[]): Account[] => {
// Group transactions by account
const accountTransactionMap = new Map<string, Transaction[]>(); const accountTransactionMap = new Map<string, Transaction[]>();
transactions.forEach(t => { transactions.forEach((transaction) => {
if (!accountTransactionMap.has(t.accountId)) { if (!accountTransactionMap.has(transaction.accountId)) {
accountTransactionMap.set(t.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) || []; const accountTransactions = accountTransactionMap.get(account.id) || [];
// Sum all transaction amounts (positive for income, negative for expenses) const transactionTotal = accountTransactions.reduce((sum, transaction) => sum + transaction.amount, 0);
const transactionTotal = accountTransactions.reduce((sum, t) => sum + t.amount, 0); const baseBalance = account.startingBalance ?? account.balance ?? 0;
// Balance is initial balance plus transaction total return { ...account, startingBalance: baseBalance, balance: baseBalance + transactionTotal };
// Note: This assumes the current balance includes initial setup balance
// In a real app, you might want to track initial balance separately
return account;
}); });
}; };
@@ -181,8 +191,18 @@ export default function BudgetDashboard() {
const allTransactions = [...newTransactions]; const allTransactions = [...newTransactions];
// Process income transactions - automatically allocate to envelopes // Process income transactions - automatically allocate to envelopes
// BUT: only if allocations weren't already created by GetPaid
newTransactions.forEach(transaction => { newTransactions.forEach(transaction => {
if (transaction.amount > 0 && !transaction.envelopeId) { if (transaction.amount > 0 && !transaction.envelopeId) {
// Check if this income already has corresponding allocations in newTransactions
const hasExistingAllocations = newTransactions.some(
t => t.accountId === transaction.accountId &&
t.envelopeId &&
t.description.includes('→')
);
// Only create auto-allocations if none exist
if (!hasExistingAllocations) {
// This is income - allocate to envelopes based on custom settings or proportionally // This is income - allocate to envelopes based on custom settings or proportionally
const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId); const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId);
const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType); const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType);
@@ -253,10 +273,16 @@ export default function BudgetDashboard() {
} }
} }
} }
}
}); });
// Add all processed transactions at once // Add all processed transactions at once and immediately recompute derived balances
setTransactions(prevTransactions => [...prevTransactions, ...allTransactions]); 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) => { 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.' }; return { isValid: false, message: 'Conflicting allocation types (fixed and percentage) detected.' };
} }
// Validate fixed allocations // Validate fixed allocations - allow partial allocation (don't require exact match)
if (hasFixed && fixedTotal !== totalAmount) { if (hasFixed && fixedTotal > totalAmount) {
return { isValid: false, message: 'Fixed allocations do not sum up to the total amount.' }; return { isValid: false, message: 'Fixed allocations exceed the total amount.' };
} }
// Validate percentage allocations // Validate percentage allocations
@@ -308,7 +334,11 @@ export default function BudgetDashboard() {
}; };
const handleSetupComplete = (accounts: Account[], envelopes: Envelope[]) => { 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); setEnvelopes(envelopes);
setTransactions([]); setTransactions([]);
setSetupCompleted(true); setSetupCompleted(true);
@@ -316,11 +346,21 @@ export default function BudgetDashboard() {
}; };
const handleAccountUpdate = (updatedAccount: Account) => { 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) => { 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) => { const handleAccountDelete = (accountId: string) => {
@@ -332,11 +372,21 @@ export default function BudgetDashboard() {
}; };
const handleTransactionEdit = (updatedTransaction: Transaction) => { 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) => { 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) => { const handleCreateEnvelope = (newEnvelope: Envelope) => {
@@ -352,6 +402,65 @@ export default function BudgetDashboard() {
)); ));
}; };
const renderLanding = () => (
<div className="px-4 py-12 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-50 text-blue-700">Capsule</span>
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight">
Take control of your money with smart envelopes
</h1>
<p className="text-lg text-gray-600 leading-relaxed">
Create envelopes, track spending, and auto-allocate income across accounts. Get set up in minutes with our guided wizard.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => setShowAuthModal(true)}
className="inline-flex justify-center items-center px-5 py-3 rounded-lg bg-blue-600 text-white font-semibold shadow hover:bg-blue-700 transition-colors"
>
Get Started
</button>
<button
onClick={() => setShowAuthModal(true)}
className="inline-flex justify-center items-center px-5 py-3 rounded-lg border border-gray-300 text-gray-700 font-semibold hover:border-gray-400 hover:text-gray-900 transition-colors"
>
Sign In
</button>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full" aria-hidden="true"></span>
Auto-save & offline-ready data
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-indigo-500 rounded-full" aria-hidden="true"></span>
Guided setup wizard
</div>
</div>
</div>
<div className="bg-white shadow-xl rounded-2xl p-6 border border-gray-100">
<div className="grid grid-cols-1 gap-4">
<div className="p-4 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200">
<p className="text-sm text-blue-800 font-medium">Total Balance</p>
<p className="text-3xl font-bold text-blue-900 mt-2">$0.00</p>
<p className="text-xs text-blue-700 mt-1">Link accounts to see your starting balance</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-green-50 to-green-100 border border-green-200">
<p className="text-sm text-green-800 font-medium">Envelopes</p>
<p className="text-3xl font-bold text-green-900 mt-2">Organize spending</p>
<p className="text-xs text-green-700 mt-1">Create categories with color-coded envelopes</p>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-indigo-50 to-indigo-100 border border-indigo-200">
<p className="text-sm text-indigo-800 font-medium">Income Auto-Allocation</p>
<p className="text-3xl font-bold text-indigo-900 mt-2">Smart distribution</p>
<p className="text-xs text-indigo-700 mt-1">Allocate paychecks by percentage or fixed amounts</p>
</div>
</div>
</div>
</div>
</div>
);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -360,7 +469,10 @@ export default function BudgetDashboard() {
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-900">Envelope Budgeting</h1> <div>
<h1 className="text-xl font-bold text-gray-900">Capsule</h1>
<p className="text-xs font-normal text-gray-500">by NeCloud</p>
</div>
</div> </div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8"> <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<button <button
@@ -405,7 +517,15 @@ export default function BudgetDashboard() {
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center space-x-3">
{user && setupCompleted && (
<button
onClick={() => setShowGetPaidModal(true)}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-md text-sm font-medium"
>
💰 Get Paid
</button>
)}
{user ? ( {user ? (
<button <button
onClick={handleSignOut} onClick={handleSignOut}
@@ -430,11 +550,15 @@ export default function BudgetDashboard() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{loading ? ( {loading ? (
<div className="text-center">Loading...</div> <div className="text-center">Loading...</div>
) : !user ? (
renderLanding()
) : ( ) : (
<> <>
{currentView === 'dashboard' && ( {currentView === 'dashboard' && (
<div className="px-4 py-6 sm:px-0"> <div className="px-4 py-6 sm:px-0">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h2>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5"> <div className="p-5">
@@ -448,7 +572,7 @@ export default function BudgetDashboard() {
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Balance</dt> <dt className="text-sm font-medium text-gray-500 truncate">Total Balance</dt>
<dd className="text-lg font-medium text-gray-900"> <dd className="text-lg font-medium text-gray-900">
${accounts.reduce((sum, acc) => sum + acc.balance, 0).toFixed(2)} ${(accounts || []).reduce((sum, acc) => sum + acc.balance, 0).toFixed(2)}
</dd> </dd>
</dl> </dl>
</div> </div>
@@ -490,6 +614,15 @@ export default function BudgetDashboard() {
</div> </div>
</div> </div>
</div> </div>
{/* Dashboard Charts */}
<DashboardCharts
accounts={accounts}
envelopes={envelopes}
transactions={transactions}
/>
{/* Recent Transactions */}
<div className="bg-white shadow overflow-hidden sm:rounded-md"> <div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6"> <div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Transactions</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">Recent Transactions</h3>
@@ -511,7 +644,9 @@ export default function BudgetDashboard() {
</p> </p>
<button <button
onClick={() => setEditingTransaction(transaction)} onClick={() => setEditingTransaction(transaction)}
className="text-indigo-600 hover:text-indigo-900 text-sm" className="text-indigo-600 hover:text-indigo-900 text-sm font-medium transition-colors"
aria-label={`Edit transaction: ${transaction.description}`}
title={`Edit transaction: ${transaction.description}`}
> >
Edit Edit
</button> </button>
@@ -521,7 +656,9 @@ export default function BudgetDashboard() {
handleTransactionDelete(transaction.id); 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 Delete
</button> </button>
@@ -547,7 +684,20 @@ export default function BudgetDashboard() {
{currentView === 'transactions' && ( {currentView === 'transactions' && (
<div className="px-4 py-6 sm:px-0"> <div className="px-4 py-6 sm:px-0">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Transactions</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Transactions</h2>
{/* Add Transactions Form */}
<DataInput
onTransactionsAdded={handleTransactionsAdded}
envelopes={envelopes}
accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))}
transactions={transactions}
/>
{/* All Transactions List */}
<div className="bg-white shadow overflow-hidden sm:rounded-md"> <div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">All Transactions</h3>
</div>
<ul className="divide-y divide-gray-200"> <ul className="divide-y divide-gray-200">
{transactions.map((transaction) => ( {transactions.map((transaction) => (
<li key={transaction.id}> <li key={transaction.id}>
@@ -565,7 +715,9 @@ export default function BudgetDashboard() {
</p> </p>
<button <button
onClick={() => setEditingTransaction(transaction)} onClick={() => setEditingTransaction(transaction)}
className="text-indigo-600 hover:text-indigo-900 text-sm" className="text-indigo-600 hover:text-indigo-900 text-sm font-medium transition-colors"
aria-label={`Edit transaction: ${transaction.description}`}
title={`Edit transaction: ${transaction.description}`}
> >
Edit Edit
</button> </button>
@@ -593,13 +745,15 @@ export default function BudgetDashboard() {
<h2 className="text-2xl font-bold text-gray-900 mb-6">Envelopes</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Envelopes</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{envelopes.map((envelope) => ( {envelopes.map((envelope) => (
<div key={envelope.id} className="bg-white overflow-hidden shadow rounded-lg"> <div key={envelope.id} className="bg-white overflow-hidden shadow rounded-lg" role="article" aria-label={`Envelope: ${envelope.name}`}>
<div className="p-5"> <div className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-4 h-4 rounded-full mr-3" className="w-4 h-4 rounded-full mr-3"
style={{ backgroundColor: envelope.color }} style={{ backgroundColor: envelope.color }}
role="img"
aria-label={`Color indicator`}
></div> ></div>
<h3 className="text-lg font-medium text-gray-900">{envelope.name}</h3> <h3 className="text-lg font-medium text-gray-900">{envelope.name}</h3>
</div> </div>
@@ -611,9 +765,18 @@ export default function BudgetDashboard() {
</button> </button>
</div> </div>
<div className="mt-4"> <div className="mt-4">
{(() => {
// 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 (
<>
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
<span>Allocated</span> <span>Allocated</span>
<span>${envelope.allocated.toFixed(2)}</span> <span>${totalAllocated.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
<span>Spent</span> <span>Spent</span>
@@ -621,8 +784,11 @@ export default function BudgetDashboard() {
</div> </div>
<div className="flex justify-between text-sm font-medium text-gray-900"> <div className="flex justify-between text-sm font-medium text-gray-900">
<span>Remaining</span> <span>Remaining</span>
<span>${(envelope.allocated - envelope.spent).toFixed(2)}</span> <span>${remaining.toFixed(2)}</span>
</div> </div>
</>
);
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -638,14 +804,14 @@ export default function BudgetDashboard() {
</div> </div>
</div> </div>
)} )}
{currentView === 'settings' && ( {currentView === 'settings' && user && setupCompleted && (
<div className="px-4 py-6 sm:px-0"> <Settings
<h2 className="text-2xl font-bold text-gray-900 mb-6">Settings</h2> user={user}
<DataExport onAccountDeleted={() => {
envelopes={envelopes} setCurrentView('dashboard');
transactions={transactions} setUser(null);
}}
/> />
</div>
)} )}
</> </>
)} )}
@@ -654,6 +820,22 @@ export default function BudgetDashboard() {
{/* Modals and Components */} {/* Modals and Components */}
{showAuthModal && <AuthModal isOpen={showAuthModal} onClose={() => setShowAuthModal(false)} onAuthSuccess={handleAuthSuccess} />} {showAuthModal && <AuthModal isOpen={showAuthModal} onClose={() => setShowAuthModal(false)} onAuthSuccess={handleAuthSuccess} />}
{showSetupWizard && <SetupWizard onComplete={handleSetupComplete} onSkip={() => setShowSetupWizard(false)} userId={user?.userId || ''} />} {showSetupWizard && <SetupWizard onComplete={handleSetupComplete} onSkip={() => setShowSetupWizard(false)} userId={user?.userId || ''} />}
{showGetPaidModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div onClick={(e) => e.stopPropagation()} className="w-full">
<GetPaid
accounts={accounts}
envelopes={envelopes}
onIncomeAdded={(txns) => {
handleTransactionsAdded(txns);
setShowGetPaidModal(false);
}}
onAccountUpdate={handleAccountUpdate}
autoOpen={true}
/>
</div>
</div>
)}
{editingTransaction && ( {editingTransaction && (
<TransactionEdit <TransactionEdit
transaction={editingTransaction} transaction={editingTransaction}
@@ -688,23 +870,6 @@ export default function BudgetDashboard() {
accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))} accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))}
/> />
)} )}
{/* Components visible after setup completion */}
{setupCompleted && (
<>
<DataInput
onTransactionsAdded={handleTransactionsAdded}
envelopes={envelopes.map(env => ({ id: env.id, name: env.name }))}
accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))}
/>
<GetPaid
accounts={accounts}
envelopes={envelopes}
onIncomeAdded={handleTransactionsAdded}
onAccountUpdate={handleAccountUpdate}
/>
</>
)}
</div> </div>
); );
} }
+8 -6
View File
@@ -153,9 +153,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
) : ( ) : (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{(accounts || []).map((account) => ( {(accounts || []).map((account) => (
<div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50"> <div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className={`w-4 h-4 rounded-full ${account.color}`}></div> <div className={`w-4 h-4 rounded-full ${account.color}`} role="img" aria-label={`Color indicator: ${account.color}`}></div>
<div> <div>
<h4 className="font-medium text-gray-900">{account.name}</h4> <h4 className="font-medium text-gray-900">{account.name}</h4>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
@@ -176,8 +176,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={() => handleEdit(account)} onClick={() => handleEdit(account)}
className="text-gray-400 hover:text-blue-600 p-1 rounded" className="text-gray-400 hover:text-blue-600 p-1 rounded transition-colors"
title="Edit account" title={`Edit ${account.name}`}
aria-label={`Edit account ${account.name}`}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@@ -185,8 +186,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
</button> </button>
<button <button
onClick={() => handleDelete(account.id)} onClick={() => handleDelete(account.id)}
className="text-gray-400 hover:text-red-600 p-1 rounded" className="text-gray-400 hover:text-red-600 p-1 rounded transition-colors"
title="Delete account" title={`Delete ${account.name}`}
aria-label={`Delete account ${account.name}`}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+3 -3
View File
@@ -78,7 +78,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(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 required
/> />
</div> </div>
@@ -91,7 +91,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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 required
minLength={6} minLength={6}
/> />
@@ -106,7 +106,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} 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 required
minLength={6} minLength={6}
/> />
+177
View File
@@ -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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Envelope Spending Chart */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Envelope Spending</h3>
{envelopeSpending.length > 0 ? (
<div className="space-y-3">
{envelopeSpending.slice(0, 5).map((env) => (
<div key={env.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">{env.name}</span>
<span className="text-gray-600">
${env.spent.toFixed(2)} / ${env.allocated.toFixed(2)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all ${env.color} ${
env.percentage > 100 ? 'bg-red-500' : ''
}`}
style={{ width: `${Math.min(env.percentage, 100)}%` }}
></div>
</div>
{env.percentage > 100 && (
<p className="text-xs text-red-600 mt-1">Over budget by ${(env.spent - env.allocated).toFixed(2)}</p>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No envelope spending data yet.</p>
)}
</div>
{/* Account Balances Chart */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Balances</h3>
{accountBalances.length > 0 ? (
<div className="space-y-3">
{accountBalances.map((acc) => (
<div key={acc.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">{acc.name}</span>
<span className={`font-semibold ${acc.balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${acc.balance.toFixed(2)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${acc.color}`}
style={{ width: `${Math.abs(acc.percentage)}%` }}
></div>
</div>
</div>
))}
<div className="pt-3 border-t border-gray-200 mt-4">
<div className="flex justify-between">
<span className="font-semibold text-gray-900">Total Balance</span>
<span className={`font-bold text-lg ${totalBalance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${totalBalance.toFixed(2)}
</span>
</div>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">No account data yet.</p>
)}
</div>
{/* Daily Spending Chart (Last 7 Days) */}
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Daily Spending (Last 7 Days)</h3>
{spendingData.length > 0 ? (
<div className="flex items-end justify-between h-48 gap-2">
{spendingData.map(([date, amount]) => {
const height = (amount / maxDailySpending) * 100;
const dateObj = new Date(date);
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
return (
<div key={date} className="flex-1 flex flex-col items-center">
<div className="w-full flex items-end justify-center h-40">
<div
className="w-full bg-blue-500 rounded-t hover:bg-blue-600 transition-colors relative group"
style={{ height: `${height}%`, minHeight: '8px' }}
>
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
${amount.toFixed(2)}
</div>
</div>
</div>
<div className="text-xs text-gray-600 mt-2">{dayName}</div>
<div className="text-xs text-gray-400">{dateObj.getDate()}</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">No spending data for the last 7 days.</p>
)}
</div>
</div>
);
}
+19 -4
View File
@@ -4,6 +4,19 @@ import { useState } from 'react';
import FileUpload, { ParsedTransaction } from './FileUpload'; import FileUpload, { ParsedTransaction } from './FileUpload';
import ManualEntry from './ManualEntry'; import ManualEntry from './ManualEntry';
interface Envelope {
id: string;
name: string;
allocated?: number;
spent?: number;
}
interface Transaction {
id: string;
envelopeId?: string;
amount: number;
}
interface DataInputProps { interface DataInputProps {
onTransactionsAdded: (transactions: Array<{ onTransactionsAdded: (transactions: Array<{
id: string; id: string;
@@ -13,17 +26,18 @@ interface DataInputProps {
date: Date; date: Date;
accountId: string; accountId: string;
}>) => void; }>) => void;
envelopes: { id: string; name: string }[]; envelopes: Envelope[];
accounts: { id: string; name: string }[]; accounts: { id: string; name: string }[];
transactions?: Transaction[];
} }
type InputMode = 'file' | 'manual'; 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<InputMode>('manual'); const [inputMode, setInputMode] = useState<InputMode>('manual');
const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => { const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => {
const transactions = parsedTransactions.map((t) => ({ const txns = parsedTransactions.map((t) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
amount: t.amount, amount: t.amount,
description: t.description, description: t.description,
@@ -32,7 +46,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
accountId: accounts?.[0]?.id || 'default-account', // Default to first account accountId: accounts?.[0]?.id || 'default-account', // Default to first account
})); }));
onTransactionsAdded(transactions); onTransactionsAdded(txns);
}; };
const handleManualTransactionAdded = (transaction: { const handleManualTransactionAdded = (transaction: {
@@ -86,6 +100,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
onTransactionAdded={handleManualTransactionAdded} onTransactionAdded={handleManualTransactionAdded}
envelopes={envelopes} envelopes={envelopes}
accounts={accounts} accounts={accounts}
transactions={transactions}
/> />
) : ( ) : (
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} /> <FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} />
+162
View File
@@ -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 (
<div className="text-center space-y-4">
<div className="text-6xl mb-4">📊</div>
<h2 className="text-2xl font-bold text-gray-900">Want to See a Demo?</h2>
<p className="text-gray-600 max-w-md mx-auto">
We can populate your budget with sample accounts and envelopes to help you get started and explore the app's features.
</p>
<div className="flex gap-3 justify-center pt-4">
<button
onClick={() => setShowDemo(true)}
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Show Me Demo Data
</button>
<button
onClick={onSkip}
className="bg-gray-200 text-gray-900 px-6 py-3 rounded-lg hover:bg-gray-300 transition-colors"
>
Skip for Now
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Demo Budget Setup</h2>
<p className="text-gray-600 mb-6">
Here's what we'll create for you:
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h3 className="font-semibold text-blue-900 mb-3">🏦 Accounts</h3>
<ul className="space-y-2">
{DEMO_DATA.accounts.map((acc) => (
<li key={acc.name} className="text-sm text-blue-800">
<span className="font-medium">{acc.name}</span> - ${acc.balance.toLocaleString()}
</li>
))}
</ul>
</div>
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<h3 className="font-semibold text-green-900 mb-3">📦 Envelopes</h3>
<ul className="space-y-2">
{DEMO_DATA.envelopes.map((env) => (
<li key={env.name} className="text-sm text-green-800">
<span className="font-medium">{env.name}</span> - ${env.allocated}/mo
{env.isRecurring && (
<span className="ml-2 text-xs bg-green-200 px-2 py-1 rounded">Recurring</span>
)}
</li>
))}
</ul>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>💡 Tip:</strong> You can always edit or delete these envelopes and accounts later. This is just a starting point!
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowDemo(false)}
className="flex-1 bg-gray-200 text-gray-900 py-3 px-4 rounded-lg hover:bg-gray-300 transition-colors"
>
← Back
</button>
<button
onClick={handleApplyDemo}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors font-semibold"
>
Apply Demo Data →
</button>
</div>
</div>
);
}
+10
View File
@@ -47,6 +47,16 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage'); const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || ''); 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 = () => { const handleDelete = () => {
if (confirm(`Are you sure you want to delete the envelope "${envelope.name}"? Transactions linked to this envelope will be unassigned.`)) { if (confirm(`Are you sure you want to delete the envelope "${envelope.name}"? Transactions linked to this envelope will be unassigned.`)) {
if (onEnvelopeDeleted) { if (onEnvelopeDeleted) {
+328 -83
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
interface Account { interface Account {
id: string; id: string;
@@ -32,69 +32,106 @@ interface Transaction {
accountId: string; accountId: string;
} }
type DistributionMethod = 'proportional' | 'equal' | 'custom';
type AllocationMap = Record<string, number>;
interface PaycheckTemplate {
amount: number;
selectedAccountId: string;
description: string;
distributionMethod: DistributionMethod;
customAllocations: AllocationMap;
}
interface GetPaidProps { interface GetPaidProps {
accounts: Account[]; accounts: Account[];
envelopes: Envelope[]; envelopes: Envelope[];
onIncomeAdded: (transactions: Transaction[]) => void; onIncomeAdded: (transactions: Transaction[]) => void;
onAccountUpdate?: (account: Account) => void; onAccountUpdate?: (account: Account) => void;
autoOpen?: boolean;
} }
export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountUpdate }: GetPaidProps) { export default function GetPaid({
const [isOpen, setIsOpen] = useState(false); accounts,
envelopes,
onIncomeAdded,
onAccountUpdate,
autoOpen = false,
}: GetPaidProps) {
const [isOpen, setIsOpen] = useState(autoOpen);
const [showPreview, setShowPreview] = useState(false);
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [selectedAccountId, setSelectedAccountId] = useState(''); const [selectedAccountId, setSelectedAccountId] = useState('');
const [description, setDescription] = useState('Paycheck'); const [description, setDescription] = useState('Paycheck');
const [distributionMethod, setDistributionMethod] = useState<'proportional' | 'equal' | 'custom'>('proportional'); const [distributionMethod, setDistributionMethod] = useState<DistributionMethod>('proportional');
const [customAllocations, setCustomAllocations] = useState<{ [envelopeId: string]: number }>({}); const [customAllocations, setCustomAllocations] = useState<AllocationMap>({});
const [previewAllocations, setPreviewAllocations] = useState<AllocationMap>({});
const [showDefaultAmountField, setShowDefaultAmountField] = useState(false); const [showDefaultAmountField, setShowDefaultAmountField] = useState(false);
const [defaultAmountInput, setDefaultAmountInput] = useState(''); const [defaultAmountInput, setDefaultAmountInput] = useState('');
const [saveTemplate, setSaveTemplate] = useState(false);
const [savedTemplate, setSavedTemplate] = useState<PaycheckTemplate | null>(null);
if (!accounts || accounts.length === 0) { useEffect(() => {
return ( if (typeof window === 'undefined') return;
<div className="bg-white rounded-lg shadow-sm border p-6"> const template = localStorage.getItem('paycheckTemplate');
<h3 className="text-lg font-semibold text-gray-900 mb-4">Get Paid</h3> if (!template) return;
<p className="text-gray-600">Please add an account first to record paychecks.</p>
</div> try {
); const parsed = JSON.parse(template) as PaycheckTemplate;
setSavedTemplate(parsed);
} catch (error) {
console.error('Error loading saved template', error);
} }
}, []);
if (!envelopes) { const accountEnvelopes = selectedAccountId
return ( ? envelopes.filter((env) => env.accountId === selectedAccountId)
<div className="bg-white rounded-lg shadow-sm border p-6"> : [];
<h3 className="text-lg font-semibold text-gray-900 mb-4">Get Paid</h3>
<p className="text-gray-600">Loading...</p>
</div>
);
}
const accountEnvelopes = selectedAccountId ? envelopes.filter(env => env.accountId === selectedAccountId) : [];
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
const calculateDistribution = () => { const calculateDistribution = (): AllocationMap => {
const paycheckAmount = parseFloat(amount); const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || accountEnvelopes.length === 0) return {}; 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') { if (distributionMethod === 'equal') {
const equalAmount = paycheckAmount / accountEnvelopes.length; const equalAmount = paycheckAmount / accountEnvelopes.length;
accountEnvelopes.forEach(env => { accountEnvelopes.forEach((env) => {
distribution[env.id] = equalAmount; distribution[env.id] = equalAmount;
}); });
} else if (distributionMethod === 'proportional') { return distribution;
accountEnvelopes.forEach(env => { }
const proportion = env.allocated / totalAllocated;
accountEnvelopes.forEach((env) => {
const proportion = totalAllocated > 0 ? env.allocated / totalAllocated : 0;
distribution[env.id] = paycheckAmount * proportion; distribution[env.id] = paycheckAmount * proportion;
}); });
} else if (distributionMethod === 'custom') {
return customAllocations;
}
return distribution; return distribution;
}; };
const handleSubmit = (e: React.FormEvent) => { const applySavedTemplate = () => {
e.preventDefault(); 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); const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || !selectedAccountId) { if (!paycheckAmount || !selectedAccountId) {
@@ -102,10 +139,22 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
return; return;
} }
if (paycheckAmount <= 0 || paycheckAmount > 999999999) {
alert('Please enter a valid paycheck amount ($0 - $999,999,999)');
return;
}
const distribution = calculateDistribution(); const distribution = calculateDistribution();
setPreviewAllocations(distribution);
setShowPreview(true);
};
const handleConfirmPaycheck = () => {
const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || !selectedAccountId) return;
const transactions: Transaction[] = []; const transactions: Transaction[] = [];
// Add the main paycheck transaction to the account
transactions.push({ transactions.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
amount: paycheckAmount, amount: paycheckAmount,
@@ -114,27 +163,53 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
accountId: selectedAccountId, accountId: selectedAccountId,
}); });
// Add allocation transactions to envelopes Object.entries(previewAllocations).forEach(([envelopeId, allocatedAmount]) => {
Object.entries(distribution).forEach(([envelopeId, allocatedAmount]) => {
if (allocatedAmount > 0) { if (allocatedAmount > 0) {
transactions.push({ transactions.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
envelopeId, envelopeId,
amount: allocatedAmount, 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(), date: new Date(),
accountId: selectedAccountId, 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); onIncomeAdded(transactions);
setIsOpen(false); setIsOpen(false);
setShowPreview(false);
setAmount(''); setAmount('');
setSelectedAccountId('');
setDescription('Paycheck'); setDescription('Paycheck');
setCustomAllocations({}); setCustomAllocations({});
setPreviewAllocations({});
setShowDefaultAmountField(false); setShowDefaultAmountField(false);
setDefaultAmountInput(''); 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 = () => { const handleSetDefaultAmount = () => {
@@ -144,13 +219,10 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
return; return;
} }
const selectedAccount = accounts.find(acc => acc.id === selectedAccountId); const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId);
if (!selectedAccount) return; if (!selectedAccount) return;
const updatedAccount = { const updatedAccount = { ...selectedAccount, defaultPaycheckAmount: defaultAmount };
...selectedAccount,
defaultPaycheckAmount: defaultAmount
};
if (onAccountUpdate) { if (onAccountUpdate) {
onAccountUpdate(updatedAccount); onAccountUpdate(updatedAccount);
@@ -163,35 +235,62 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
}; };
const handleUseDefaultAmount = () => { const handleUseDefaultAmount = () => {
const selectedAccount = accounts.find(acc => acc.id === selectedAccountId); const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId);
if (selectedAccount?.defaultPaycheckAmount) { if (selectedAccount?.defaultPaycheckAmount) {
setAmount(selectedAccount.defaultPaycheckAmount.toString()); setAmount(selectedAccount.defaultPaycheckAmount.toString());
} }
}; };
const distribution = calculateDistribution(); const distribution = calculateDistribution();
const totalDistributed = Object.values(distribution).reduce((sum, amt) => sum + amt, 0); const numericAmount = parseFloat(amount || '0') || 0;
const remaining = parseFloat(amount) - totalDistributed; const totalDistributed = Object.values(distribution).reduce((sum, value) => sum + value, 0);
const remaining = numericAmount - totalDistributed;
return ( return (
<> <>
{!autoOpen && (
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center space-x-2" className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center space-x-2"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
/>
</svg> </svg>
<span>Get Paid</span> <span>Get Paid</span>
</button> </button>
)}
{isOpen && ( {isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">Record Paycheck</h3> <h3 className="text-xl font-bold text-gray-900 mb-4">Record Paycheck</h3>
{savedTemplate && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">Saved Template Available</p>
<p className="text-xs text-gray-600">
Amount: ${savedTemplate.amount?.toFixed(2)} • {savedTemplate.description}
</p>
</div>
<button
type="button"
onClick={applySavedTemplate}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap"
>
Use Template
</button>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Paycheck Amount</label> <label className="block text-sm font-medium text-gray-700 mb-1">Paycheck Amount</label>
@@ -201,19 +300,23 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
type="number" type="number"
step="0.01" step="0.01"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} 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" 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" placeholder="0.00"
required required
/> />
</div> </div>
{selectedAccountId && accounts.find(acc => acc.id === selectedAccountId)?.defaultPaycheckAmount && ( {selectedAccountId &&
accounts.find((acc) => acc.id === selectedAccountId)?.defaultPaycheckAmount && (
<button <button
type="button" type="button"
onClick={handleUseDefaultAmount} onClick={handleUseDefaultAmount}
className="mt-1 text-sm text-blue-600 hover:text-blue-800" className="mt-1 text-sm text-blue-600 hover:text-blue-800"
> >
Use default: ${accounts.find(acc => acc.id === selectedAccountId)?.defaultPaycheckAmount?.toFixed(2)} Use default: $
{accounts
.find((acc) => acc.id === selectedAccountId)
?.defaultPaycheckAmount?.toFixed(2)}
</button> </button>
)} )}
{selectedAccountId && ( {selectedAccountId && (
@@ -233,8 +336,8 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
type="number" type="number"
step="0.01" step="0.01"
value={defaultAmountInput} value={defaultAmountInput}
onChange={(e) => setDefaultAmountInput(e.target.value)} onChange={(event) => setDefaultAmountInput(event.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm border border-gray-300 rounded" className="w-full pl-8 pr-3 py-1 text-sm border border-gray-300 rounded bg-white text-gray-900 placeholder-gray-400"
placeholder="0.00" placeholder="0.00"
/> />
</div> </div>
@@ -253,12 +356,19 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<label className="block text-sm font-medium text-gray-700 mb-1">Deposit Account</label> <label className="block text-sm font-medium text-gray-700 mb-1">Deposit Account</label>
<select <select
value={selectedAccountId} value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)} onChange={(event) => setSelectedAccountId(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" 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 required
> >
<option value="">Select account...</option> <option value="">Select account...</option>
{accounts.filter(acc => acc.type !== 'credit_card' && acc.type !== 'mortgage' && acc.type !== 'loan').map((account) => ( {accounts
.filter(
(account) =>
account.type !== 'credit_card' &&
account.type !== 'mortgage' &&
account.type !== 'loan',
)
.map((account) => (
<option key={account.id} value={account.id}> <option key={account.id} value={account.id}>
{account.name} ({account.type.replace('_', ' ')}) {account.name} ({account.type.replace('_', ' ')})
</option> </option>
@@ -272,58 +382,71 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<input <input
type="text" type="text"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} 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" 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" placeholder="Paycheck description"
/> />
</div> </div>
{/* Distribution Method */} <div className="flex items-center">
<input
type="checkbox"
id="saveTemplate"
checked={saveTemplate}
onChange={(event) => setSaveTemplate(event.target.checked)}
className="mr-2"
/>
<label htmlFor="saveTemplate" className="text-sm text-gray-700">
Save this information to use next time
</label>
</div>
{selectedAccountId && accountEnvelopes.length > 0 && ( {selectedAccountId && accountEnvelopes.length > 0 && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Income Distribution Method</label> <label className="block text-sm font-medium text-gray-800 mb-2">Income Distribution Method</label>
<div className="space-y-2"> <div className="space-y-2 text-gray-900">
<label className="flex items-center"> <label className="flex items-center gap-2">
<input <input
type="radio" type="radio"
value="proportional" value="proportional"
checked={distributionMethod === 'proportional'} checked={distributionMethod === 'proportional'}
onChange={(e) => setDistributionMethod(e.target.value as 'proportional')} onChange={(event) =>
className="mr-2" setDistributionMethod(event.target.value as DistributionMethod)
}
className="accent-blue-600"
/> />
<span className="text-sm">Proportional to envelope allocations</span> <span className="text-sm font-medium">Proportional to envelope allocations</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center gap-2">
<input <input
type="radio" type="radio"
value="equal" value="equal"
checked={distributionMethod === 'equal'} checked={distributionMethod === 'equal'}
onChange={(e) => setDistributionMethod(e.target.value as 'equal')} onChange={(event) => setDistributionMethod(event.target.value as DistributionMethod)}
className="mr-2" className="accent-blue-600"
/> />
<span className="text-sm">Equal distribution to all envelopes</span> <span className="text-sm font-medium">Equal distribution to all envelopes</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center gap-2">
<input <input
type="radio" type="radio"
value="custom" value="custom"
checked={distributionMethod === 'custom'} checked={distributionMethod === 'custom'}
onChange={(e) => setDistributionMethod(e.target.value as 'custom')} onChange={(event) => setDistributionMethod(event.target.value as DistributionMethod)}
className="mr-2" className="accent-blue-600"
/> />
<span className="text-sm">Custom amounts</span> <span className="text-sm font-medium">Custom amounts</span>
</label> </label>
</div> </div>
</div> </div>
{/* Distribution Preview */}
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Income Distribution Preview</h4> <h4 className="font-medium text-gray-900 mb-3">Income Distribution Preview</h4>
<div className="space-y-2"> <div className="space-y-2">
{accountEnvelopes.map((envelope) => { {accountEnvelopes.map((envelope) => {
const allocatedAmount = distribution[envelope.id] || 0; 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 ( return (
<div key={envelope.id} className="flex items-center justify-between"> <div key={envelope.id} className="flex items-center justify-between">
@@ -336,18 +459,20 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<input <input
type="number" type="number"
step="0.01" step="0.01"
value={customAllocations[envelope.id] || ''} value={customAllocations[envelope.id] ?? ''}
onChange={(e) => setCustomAllocations(prev => ({ onChange={(event) =>
setCustomAllocations((prev) => ({
...prev, ...prev,
[envelope.id]: parseFloat(e.target.value) || 0 [envelope.id]: parseFloat(event.target.value) || 0,
}))} }))
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded" }
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" placeholder="0.00"
/> />
) : ( ) : (
<span className="text-sm font-medium">${allocatedAmount.toFixed(2)}</span> <span className="text-sm font-semibold text-gray-900">${allocatedAmount.toFixed(2)}</span>
)} )}
<span className="text-xs text-gray-500">({percentage.toFixed(1)}%)</span> <span className="text-xs font-semibold text-gray-700">({percentage.toFixed(1)}%)</span>
</div> </div>
</div> </div>
); );
@@ -374,7 +499,6 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
</> </>
)} )}
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t"> <div className="flex justify-end space-x-3 pt-4 border-t">
<button <button
type="button" type="button"
@@ -388,13 +512,134 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
disabled={!amount || !selectedAccountId || (accountEnvelopes.length > 0 && Math.abs(remaining) >= 0.01)} disabled={!amount || !selectedAccountId || (accountEnvelopes.length > 0 && Math.abs(remaining) >= 0.01)}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed" className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
> >
Record Paycheck Review & Confirm
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
)} )}
{showPreview && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">Confirm Paycheck Allocation</h3>
<div className="mb-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">Paycheck Amount:</span>
<span className="text-2xl font-bold text-blue-600">${numericAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm text-gray-600">Deposit to:</span>
<span className="text-sm font-medium text-gray-900">
{accounts.find((acc) => acc.id === selectedAccountId)?.name}
</span>
</div>
{description && (
<div className="flex justify-between items-center mt-1">
<span className="text-sm text-gray-600">Description:</span>
<span className="text-sm text-gray-900">{description}</span>
</div>
)}
</div>
</div>
<h4 className="font-semibold text-gray-900 mb-3">Envelope Allocations</h4>
<p className="text-sm text-gray-600 mb-4">Review and adjust how this paycheck will be distributed:</p>
<div className="space-y-3 mb-6">
{accountEnvelopes.map((envelope) => {
const allocatedAmount = previewAllocations[envelope.id] || 0;
const percentage = numericAmount > 0 ? (allocatedAmount / numericAmount) * 100 : 0;
return (
<div key={envelope.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<div className={`w-4 h-4 rounded-full ${envelope.color}`}></div>
<span className="font-medium text-gray-900">{envelope.name}</span>
</div>
<span className="text-xs text-gray-500">{percentage.toFixed(1)}% of paycheck</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 font-medium">Amount:</span>
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600 font-semibold">$</span>
<input
type="number"
step="0.01"
value={allocatedAmount.toFixed(2)}
onChange={(event) => 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"
/>
</div>
</div>
</div>
);
})}
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">Total Allocated:</span>
<span className="text-xl font-bold text-green-600">
${Object.values(previewAllocations).reduce((sum, value) => sum + value, 0).toFixed(2)}
</span>
</div>
{(() => {
const totalAllocatedPreview = Object.values(previewAllocations).reduce(
(sum, value) => sum + value,
0,
);
const remainingAmount = numericAmount - totalAllocatedPreview;
if (Math.abs(remainingAmount) >= 0.01) {
return (
<div className="flex justify-between items-center mt-2">
<span className="text-sm text-gray-600">Unallocated:</span>
<span
className={`text-sm font-medium ${remainingAmount >= 0 ? 'text-orange-600' : 'text-red-600'}`}
>
${remainingAmount.toFixed(2)} {remainingAmount < 0 && '(over allocated!)'}
</span>
</div>
);
}
return null;
})()}
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
onClick={() => setShowPreview(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Go Back
</button>
<button
type="button"
onClick={handleConfirmPaycheck}
disabled={(() => {
const totalAllocatedPreview = Object.values(previewAllocations).reduce(
(sum, value) => sum + value,
0,
);
const remainingAmount = numericAmount - totalAllocatedPreview;
return Math.abs(remainingAmount) >= 0.01;
})()}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Confirm & Apply Paycheck
</button>
</div>
</div>
</div>
)}
</> </>
); );
} }
+71 -18
View File
@@ -2,6 +2,19 @@
import { useState, useEffect } from 'react'; 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 { interface ManualEntryProps {
onTransactionAdded: (transaction: { onTransactionAdded: (transaction: {
amount: number; amount: number;
@@ -10,11 +23,12 @@ interface ManualEntryProps {
envelopeId?: string; envelopeId?: string;
accountId: string; accountId: string;
}) => void; }) => void;
envelopes: { id: string; name: string }[]; envelopes: Envelope[];
accounts: { id: string; name: string }[]; 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 [amount, setAmount] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]); const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
@@ -33,8 +47,36 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
e.preventDefault(); e.preventDefault();
const numAmount = parseFloat(amount); const numAmount = parseFloat(amount);
if (isNaN(numAmount) || !description.trim()) { const descriptionTrimmed = description.trim();
alert('Please enter a valid amount and description');
// 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; return;
} }
@@ -42,7 +84,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
onTransactionAdded({ onTransactionAdded({
amount: transactionAmount, amount: transactionAmount,
description: description.trim(), description: descriptionTrimmed,
date: new Date(date), date: new Date(date),
envelopeId: selectedEnvelope || undefined, envelopeId: selectedEnvelope || undefined,
accountId: selectedAccount, accountId: selectedAccount,
@@ -92,7 +134,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
placeholder="0.00" 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 required
/> />
</div> </div>
@@ -106,7 +148,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} 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 required
/> />
</div> </div>
@@ -121,7 +163,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter transaction description" 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 required
/> />
</div> </div>
@@ -133,14 +175,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
<select <select
value={selectedEnvelope} value={selectedEnvelope}
onChange={(e) => setSelectedEnvelope(e.target.value)} onChange={(e) => setSelectedEnvelope(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"
> >
<option value="">Select an envelope...</option> <option value="">Select an envelope...</option>
{envelopes.map((envelope) => ( {envelopes.map((envelope) => {
// Calculate remaining balance
const incomeAllocated = transactions
.filter(t => t.envelopeId === envelope.id && t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const totalAllocated = (envelope.allocated || 0) + incomeAllocated;
const remaining = totalAllocated - (envelope.spent || 0);
return (
<option key={envelope.id} value={envelope.id}> <option key={envelope.id} value={envelope.id}>
{envelope.name} {envelope.name} — ${remaining.toFixed(2)} remaining
</option> </option>
))} );
})}
</select> </select>
</div> </div>
@@ -151,7 +202,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
<select <select
value={selectedAccount} value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)} onChange={(e) => setSelectedAccount(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 required
> >
<option value="">Select an account...</option> <option value="">Select an account...</option>
@@ -163,24 +214,26 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
</select> </select>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4 p-3 bg-gray-50 rounded-md">
<label className="flex items-center"> <label className="flex items-center cursor-pointer">
<input <input
type="radio" type="radio"
checked={isExpense} checked={isExpense}
onChange={() => setIsExpense(true)} onChange={() => setIsExpense(true)}
className="mr-2" className="mr-2"
aria-label="Transaction type: Expense"
/> />
<span className="text-sm text-gray-700">Expense</span> <span className="text-sm font-medium text-red-600">💸 Expense (Negative)</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center cursor-pointer">
<input <input
type="radio" type="radio"
checked={!isExpense} checked={!isExpense}
onChange={() => setIsExpense(false)} onChange={() => setIsExpense(false)}
className="mr-2" className="mr-2"
aria-label="Transaction type: Income"
/> />
<span className="text-sm text-gray-700">Income</span> <span className="text-sm font-medium text-green-600">💰 Income (Positive)</span>
</label> </label>
</div> </div>
@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
interface RecurringTransactionManagerProps {
transactions: Array<{
id: string;
description: string;
amount: number;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
lastAppliedDate?: string;
}>;
onApplyRecurring: (transactions: any[]) => void;
}
export default function RecurringTransactionManager({ transactions, onApplyRecurring }: RecurringTransactionManagerProps) {
const [expanded, setExpanded] = useState(false);
const getNextRecurringDate = (lastDate: string | undefined, frequency: string | undefined): Date | null => {
if (!lastDate || !frequency) return null;
const last = new Date(lastDate);
const now = new Date();
switch (frequency) {
case 'weekly':
const nextWeek = new Date(last);
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek <= now ? nextWeek : null;
case 'monthly':
const nextMonth = new Date(last);
nextMonth.setMonth(nextMonth.getMonth() + 1);
return nextMonth <= now ? nextMonth : null;
case 'yearly':
const nextYear = new Date(last);
nextYear.setFullYear(nextYear.getFullYear() + 1);
return nextYear <= now ? nextYear : null;
default:
return null;
}
};
const recurringTransactions = transactions.filter(t => t.isRecurring);
const upcomingTransactions = recurringTransactions.filter(t => getNextRecurringDate(t.lastAppliedDate, t.recurringFrequency));
if (recurringTransactions.length === 0) {
return null;
}
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex justify-between items-center font-semibold text-blue-900 hover:text-blue-700"
>
<span>🔄 Recurring Transactions ({recurringTransactions.length})</span>
<span>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="mt-4 space-y-3">
<p className="text-sm text-blue-800">
You have <strong>{upcomingTransactions.length}</strong> recurring transaction(s) that are due to be applied.
</p>
{upcomingTransactions.map(tx => (
<div key={tx.id} className="bg-white p-3 rounded border border-blue-100">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{tx.description}</p>
<p className="text-xs text-gray-500">
Frequency: <span className="capitalize font-semibold">{tx.recurringFrequency}</span>
</p>
</div>
<p className={`font-semibold ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${Math.abs(tx.amount).toFixed(2)}
</p>
</div>
</div>
))}
{upcomingTransactions.length > 0 && (
<button
onClick={() => {
const newTransactions = upcomingTransactions.map(tx => ({
...tx,
lastAppliedDate: new Date().toISOString().split('T')[0],
}));
onApplyRecurring(newTransactions);
setExpanded(false);
}}
className="w-full mt-3 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
>
Apply Now
</button>
)}
</div>
)}
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
'use client';
import { useState } from 'react';
import { AuthService } from '../lib/authService';
import { DataService } from '../lib/dataService';
interface SettingsProps {
user: { userId: string; email: string };
onAccountDeleted: () => void;
}
export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [activeTab, setActiveTab] = useState<'password' | 'export' | 'delete'>('password');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
const [deleteInput, setDeleteInput] = useState('');
const [clearDataInput, setClearDataInput] = useState('');
const [resetEmail, setResetEmail] = useState('');
const [resetCode, setResetCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
const handlePasswordReset = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
setLoading(true);
try {
// In a real app, this would send an email with a reset code
// For now, we'll show a simplified flow
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resetEmail || user.email, code: resetCode, newPassword }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password reset failed');
}
setMessage({ type: 'success', text: 'Password reset successfully! Please sign in again.' });
setResetCode('');
setNewPassword('');
setTimeout(() => AuthService.signOut(), 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Password reset failed',
});
} finally {
setLoading(false);
}
};
const handleExportData = async (format: 'json' | 'csv') => {
try {
const userData = await DataService.loadUserData();
if (!userData) throw new Error('No data to export');
let content: string;
let filename: string;
if (format === 'json') {
content = JSON.stringify(userData, null, 2);
filename = `capsule-backup-${new Date().toISOString().split('T')[0]}.json`;
} else {
// CSV export for transactions
const rows = [['Date', 'Description', 'Amount', 'Type', 'Account']];
userData.transactions?.forEach((t: any) => {
rows.push([
new Date(t.date).toLocaleDateString(),
t.description,
Math.abs(t.amount).toString(),
t.amount < 0 ? 'Expense' : 'Income',
userData.accounts?.find((a: any) => a.id === t.accountId)?.name || 'Unknown',
]);
});
content = rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
filename = `capsule-transactions-${new Date().toISOString().split('T')[0]}.csv`;
}
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setMessage({ type: 'success', text: `Data exported successfully as ${format.toUpperCase()}` });
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Export failed',
});
}
};
const handleDeleteAccount = async () => {
if (deleteInput !== 'DELETE' || !showDeleteConfirm) return;
setLoading(true);
try {
const response = await fetch('/api/auth/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.userId,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Account deletion failed');
}
setMessage({ type: 'success', text: 'Account deleted successfully. Signing out...' });
setTimeout(async () => {
await AuthService.signOut();
onAccountDeleted();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Account deletion failed',
});
} finally {
setLoading(false);
}
};
const handleClearData = async () => {
if (clearDataInput !== 'CLEAR ALL' || !showClearDataConfirm) return;
setLoading(true);
try {
await DataService.saveUserData({
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
});
setMessage({ type: 'success', text: 'All data cleared successfully. Reloading app...' });
setClearDataInput('');
setShowClearDataConfirm(false);
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to clear data',
});
} finally {
setLoading(false);
}
};
return (
<div className="px-4 py-6 sm:px-0">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Settings</h2>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex space-x-8" aria-label="Settings tabs">
{[
{ id: 'password', label: 'Password & Security' },
{ id: 'export', label: 'Data & Export' },
{ id: 'delete', label: 'Account' },
].map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
setMessage(null);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Messages */}
{message && (
<div
className={`mb-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Reset Password</h3>
<form onSubmit={handlePasswordReset} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={resetEmail || user.email}
onChange={(e) => setResetEmail(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="your@email.com"
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800">
<p className="font-medium mb-1">Reset Code</p>
<p>
In a production app, a reset code would be sent to your email. For testing, enter any code and your new password.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reset Code
</label>
<input
type="text"
value={resetCode}
onChange={(e) => setResetCode(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="Enter code from email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(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="••••••••"
/>
</div>
<button
type="submit"
disabled={loading || !resetCode || !newPassword}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
)}
{/* Export Tab */}
{activeTab === 'export' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export Your Data</h3>
<p className="text-gray-600 mb-4">
Download a backup of your budget data in your preferred format.
</p>
<div className="space-y-3">
<button
onClick={() => handleExportData('json')}
className="w-full bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700 transition-colors text-left flex justify-between items-center"
>
<span>📋 Export as JSON (Full Backup)</span>
<span className="text-sm">Recommended</span>
</button>
<button
onClick={() => handleExportData('csv')}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition-colors text-left"
>
📊 Export Transactions as CSV
</button>
</div>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>💡 Tip:</strong> Regularly export your data as a backup. You can import it later if needed.
</p>
</div>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-orange-900 mb-4">⚠️ Clear All Data</h3>
{!showClearDataConfirm ? (
<div className="space-y-4">
<p className="text-sm text-orange-800">
This will delete all your accounts, envelopes, and transactions, but your account will remain active. You can set up new data after clearing.
</p>
<button
onClick={() => setShowClearDataConfirm(true)}
className="w-full bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 transition-colors"
>
I understand, Clear All Data
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-orange-100 border border-orange-300 rounded-md p-3">
<p className="text-sm font-medium text-orange-900 mb-2">
To confirm clearing all data, type "<strong>CLEAR ALL</strong>" below:
</p>
<input
type="text"
value={clearDataInput}
onChange={(e) => setClearDataInput(e.target.value.toUpperCase())}
className="w-full px-3 py-2 border border-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 bg-white text-gray-900"
placeholder="Type CLEAR ALL"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowClearDataConfirm(false);
setClearDataInput('');
}}
className="flex-1 bg-gray-200 text-gray-900 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleClearData}
disabled={clearDataInput !== 'CLEAR ALL' || loading}
className="flex-1 bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Clearing...' : 'Clear All Data'}
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Delete Tab */}
{activeTab === 'delete' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<h3 className="text-lg font-semibold text-red-900 mb-4">⚠️ Delete Account</h3>
{!showDeleteConfirm ? (
<div className="space-y-4">
<p className="text-sm text-red-800">
Deleting your account is permanent and cannot be undone. All your budget data, transactions, and envelopes will be permanently deleted.
</p>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
I understand, Delete My Account
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-red-100 border border-red-300 rounded-md p-3">
<p className="text-sm font-medium text-red-900 mb-2">
To confirm deletion, type "<strong>DELETE</strong>" below:
</p>
<input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value.toUpperCase())}
className="w-full px-3 py-2 border border-red-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 bg-white text-gray-900"
placeholder="Type DELETE"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteInput('');
}}
className="flex-1 bg-gray-200 text-gray-900 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleDeleteAccount}
disabled={deleteInput !== 'DELETE' || loading}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Deleting...' : 'Permanently Delete'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
+150 -47
View File
@@ -104,6 +104,98 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500' 'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
]; ];
const generateDemoData = () => {
const demoAccounts: Account[] = [
{
id: 'checking-demo',
name: 'Main Checking',
type: 'checking',
balance: 5000,
color: 'bg-blue-500',
isActive: true,
institution: 'Demo Bank',
},
{
id: 'savings-demo',
name: 'Emergency Fund',
type: 'savings',
balance: 10000,
color: 'bg-green-500',
isActive: true,
institution: 'Demo Bank',
},
];
const demoEnvelopes: Envelope[] = [
{
id: 'env-groceries',
name: 'Groceries',
allocated: 500,
spent: 0,
color: 'bg-orange-500',
accountId: 'checking-demo',
},
{
id: 'env-dining',
name: 'Dining & Entertainment',
allocated: 300,
spent: 0,
color: 'bg-pink-500',
accountId: 'checking-demo',
},
{
id: 'env-utilities',
name: 'Utilities',
allocated: 200,
spent: 0,
color: 'bg-red-500',
accountId: 'checking-demo',
},
{
id: 'env-transportation',
name: 'Transportation',
allocated: 400,
spent: 0,
color: 'bg-purple-500',
accountId: 'checking-demo',
},
{
id: 'env-personal',
name: 'Personal Care',
allocated: 150,
spent: 0,
color: 'bg-indigo-500',
accountId: 'checking-demo',
},
];
return { demoAccounts, demoEnvelopes };
};
const handleSkipWithDemo = async () => {
if (!userId) {
setErrorMessage('No user ID provided');
return;
}
setIsSaving(true);
setErrorMessage(null);
try {
const { demoAccounts, demoEnvelopes } = generateDemoData();
DataService.setUserId(userId);
await DataService.saveUserData({
accounts: demoAccounts,
envelopes: demoEnvelopes,
transactions: [],
setupCompleted: true,
});
onComplete(demoAccounts, demoEnvelopes);
} catch (error) {
console.error('Error creating demo data:', error);
setErrorMessage(error instanceof Error ? error.message : 'Failed to create demo data');
setIsSaving(false);
}
};
const handleAddAccount = () => { const handleAddAccount = () => {
if (!newAccount.name || !newAccount.type) return; if (!newAccount.name || !newAccount.type) return;
@@ -206,7 +298,13 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
transactions: [], transactions: [],
setupCompleted: true, setupCompleted: true,
}); });
try {
onComplete(accountsWithDefaults, envelopesWithAllocations); onComplete(accountsWithDefaults, envelopesWithAllocations);
} catch (callbackError) {
console.error('Error in setup completion callback:', callbackError);
setErrorMessage('Setup completed but failed to update display. Please refresh.');
}
} catch (error) { } catch (error) {
console.error('Error saving setup data:', error); console.error('Error saving setup data:', error);
setErrorMessage('Failed to save setup data. Please try again.'); setErrorMessage('Failed to save setup data. Please try again.');
@@ -219,7 +317,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="text-center"> <div className="text-center">
<div className="mb-8"> <div className="mb-8">
<div className="text-6xl mb-4">💰</div> <div className="text-6xl mb-4">💰</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to Envelope Budgeting</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to Capsule</h1>
<p className="text-gray-600 mb-4">Let&apos;s set up your budget to get started with secure, organized financial tracking.</p> <p className="text-gray-600 mb-4">Let&apos;s set up your budget to get started with secure, organized financial tracking.</p>
</div> </div>
@@ -231,10 +329,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
Get Started Get Started
</button> </button>
<button <button
onClick={onSkip} onClick={handleSkipWithDemo}
className="w-full text-gray-600 hover:text-gray-800 py-2" disabled={isSaving}
className="w-full text-gray-600 hover:text-gray-800 py-2 disabled:opacity-50"
> >
Skip Setup (Use Demo Data) {isSaving ? 'Creating demo data...' : 'Skip Setup (Use Demo Data)'}
</button> </button>
</div> </div>
</div> </div>
@@ -289,7 +388,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Existing Accounts */} {/* Existing Accounts */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Your Accounts</h3> <h3 className="font-semibold text-gray-900 mb-3">Your Accounts</h3>
{(accounts || []).length === 0 ? ( {(accounts || []).length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">No accounts added yet</p> <p className="text-gray-500 dark:text-gray-400 text-center py-8">No accounts added yet</p>
) : ( ) : (
@@ -299,11 +398,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${account.color}`}></div> <div className={`w-4 h-4 rounded-full ${account.color}`}></div>
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">{account.name}</p> <p className="font-medium text-gray-900 ">{account.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{account.type.replace('_', ' ').toUpperCase()}</p> <p className="text-sm text-gray-500 dark:text-gray-400">{account.type.replace('_', ' ').toUpperCase()}</p>
</div> </div>
</div> </div>
<p className="font-semibold text-gray-900 dark:text-white">${account.balance.toFixed(2)}</p> <p className="font-semibold text-gray-900 ">${account.balance.toFixed(2)}</p>
</div> </div>
))} ))}
</div> </div>
@@ -343,27 +442,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Add Account Modal */} {/* Add Account Modal */}
{showAddAccount && ( {showAddAccount && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Add Account</h3> <h3 className="text-xl font-bold text-gray-900 mb-4">Add Account</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account Name</label> <label className="block text-sm font-medium text-gray-700 mb-1">Account Name</label>
<input <input
type="text" type="text"
value={newAccount.name || ''} value={newAccount.name || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => 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" placeholder="e.g., Main Checking"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account Type</label> <label className="block text-sm font-medium text-gray-700 mb-1">Account Type</label>
<select <select
value={newAccount.type || 'checking'} value={newAccount.type || 'checking'}
onChange={(e) => setNewAccount(prev => ({ ...prev, type: e.target.value as Account['type'] }))} onChange={(e) => setNewAccount(prev => ({ ...prev, type: e.target.value as Account['type'] }))}
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"
> >
{accountTypes.map((type) => ( {accountTypes.map((type) => (
<option key={type.value} value={type.value}> <option key={type.value} value={type.value}>
@@ -374,30 +473,30 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Balance</label> <label className="block text-sm font-medium text-gray-700 mb-1">Current Balance</label>
<input <input
type="number" type="number"
step="0.01" step="0.01"
value={newAccount.balance || ''} value={newAccount.balance || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, balance: parseFloat(e.target.value) || 0 }))} onChange={(e) => 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" placeholder="0.00"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Institution (Optional)</label> <label className="block text-sm font-medium text-gray-700 mb-1">Institution (Optional)</label>
<input <input
type="text" type="text"
value={newAccount.institution || ''} value={newAccount.institution || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, institution: e.target.value }))} onChange={(e) => 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" placeholder="e.g., Bank of America"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label> <label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
<div className="flex space-x-2"> <div className="flex space-x-2">
{envelopeColors.slice(0, 6).map((color) => ( {envelopeColors.slice(0, 6).map((color) => (
<button <button
@@ -440,7 +539,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Existing Envelopes */} {/* Existing Envelopes */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Your Envelopes</h3> <h3 className="font-semibold text-gray-900 mb-3">Your Envelopes</h3>
{envelopes.length === 0 ? ( {envelopes.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">No envelopes created yet</p> <p className="text-gray-500 dark:text-gray-400 text-center py-8">No envelopes created yet</p>
) : ( ) : (
@@ -452,11 +551,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${envelope.color}`}></div> <div className={`w-4 h-4 rounded-full ${envelope.color}`}></div>
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">{envelope.name}</p> <p className="font-medium text-gray-900 ">{envelope.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{account?.name}</p> <p className="text-sm text-gray-500 dark:text-gray-400">{account?.name}</p>
</div> </div>
</div> </div>
<p className="font-semibold text-gray-900 dark:text-white">${envelope.allocated.toFixed(2)}</p> <p className="font-semibold text-gray-900 ">${envelope.allocated.toFixed(2)}</p>
</div> </div>
); );
})} })}
@@ -473,7 +572,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
checked={includeStandardEnvelopes} checked={includeStandardEnvelopes}
onChange={(e) => setIncludeStandardEnvelopes(e.target.checked)} onChange={(e) => setIncludeStandardEnvelopes(e.target.checked)}
/> />
<span className="font-medium text-gray-900 dark:text-white">Add standard envelopes</span> <span className="font-medium text-gray-900 ">Add standard envelopes</span>
</label> </label>
<div className="mt-3"> <div className="mt-3">
@@ -551,27 +650,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Add Envelope Modal */} {/* Add Envelope Modal */}
{showAddEnvelope && ( {showAddEnvelope && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Add Envelope</h3> <h3 className="text-xl font-bold text-gray-900 mb-4">Add Envelope</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Envelope Name</label> <label className="block text-sm font-medium text-gray-700 mb-1">Envelope Name</label>
<input <input
type="text" type="text"
value={newEnvelope.name || ''} value={newEnvelope.name || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => 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" placeholder="e.g., Groceries"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account</label> <label className="block text-sm font-medium text-gray-700 mb-1">Account</label>
<select <select
value={newEnvelope.accountId || ''} value={newEnvelope.accountId || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, accountId: e.target.value }))} onChange={(e) => setNewEnvelope(prev => ({ ...prev, accountId: 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"
> >
<option value="">Select an account</option> <option value="">Select an account</option>
{accounts.map((account) => ( {accounts.map((account) => (
@@ -583,19 +682,19 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Monthly Budget</label> <label className="block text-sm font-medium text-gray-700 mb-1">Monthly Budget</label>
<input <input
type="number" type="number"
step="0.01" step="0.01"
value={newEnvelope.allocated || ''} value={newEnvelope.allocated || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, allocated: parseFloat(e.target.value) || 0 }))} onChange={(e) => 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" placeholder="0.00"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label> <label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
<div className="flex space-x-2"> <div className="flex space-x-2">
{envelopeColors.map((color) => ( {envelopeColors.map((color) => (
<button <button
@@ -638,8 +737,8 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Income Allocation Settings */} {/* Income Allocation Settings */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Envelope Income Allocations</h3> <h3 className="font-semibold text-gray-900 mb-3">Envelope Income Allocations</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4"> <p className="text-sm text-gray-600 mb-4">
Configure how much of your income goes to each envelope automatically when you get paid. Configure how much of your income goes to each envelope automatically when you get paid.
You can set percentages or fixed amounts. You can set percentages or fixed amounts.
</p> </p>
@@ -666,7 +765,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocation Type</label> <label className="block text-sm font-medium text-gray-700 mb-1">Allocation Type</label>
<select <select
value={allocation?.type || 'percentage'} value={allocation?.type || 'percentage'}
onChange={(e) => setIncomeAllocations(prev => ({ onChange={(e) => setIncomeAllocations(prev => ({
@@ -676,7 +775,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
value: allocation?.value || 0 value: allocation?.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-gray-400"
> >
<option value="percentage">Percentage</option> <option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option> <option value="fixed">Fixed Amount</option>
@@ -684,7 +783,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{allocation?.type === 'fixed' ? 'Amount ($)' : 'Percentage (%)'} {allocation?.type === 'fixed' ? 'Amount ($)' : 'Percentage (%)'}
</label> </label>
<input <input
@@ -701,7 +800,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
} }
}))} }))}
placeholder={allocation?.type === 'fixed' ? '0.00' : '0'} placeholder={allocation?.type === 'fixed' ? '0.00' : '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-gray-400"
/> />
</div> </div>
@@ -772,8 +871,8 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Default Paycheck Amounts */} {/* Default Paycheck Amounts */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Default Paycheck Amounts</h3> <h3 className="font-semibold text-gray-900 mb-3">Default Paycheck Amounts</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4"> <p className="text-sm text-gray-600 mb-4">
Set default amounts for each account to speed up paycheck entry. You can always change these later. Set default amounts for each account to speed up paycheck entry. You can always change these later.
</p> </p>
@@ -795,7 +894,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex-1"> <div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default Paycheck Amount</label> <label className="block text-sm font-medium text-gray-700 mb-1">Default Paycheck Amount</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span> <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
<input <input
@@ -808,7 +907,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
[account.id]: parseFloat(e.target.value) || 0 [account.id]: parseFloat(e.target.value) || 0
}))} }))}
placeholder="0.00" 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"
/> />
</div> </div>
</div> </div>
@@ -854,7 +953,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="mb-8"> <div className="mb-8">
<div className="text-6xl mb-4">🎉</div> <div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2> <h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2>
<p className="text-gray-600">Your envelope budgeting system is ready to use.</p> <p className="text-gray-600">Your Capsule budget is ready to use.</p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-6 mb-8"> <div className="bg-gray-50 rounded-lg p-6 mb-8">
@@ -908,15 +1007,19 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={handleBackdropClick}> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={handleBackdropClick}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}> <div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
{/* Close button */} {/* Close button - only on welcome step, or if closing from welcome, create demo data */}
{currentStep === 'welcome' && (
<button <button
onClick={onSkip} onClick={handleSkipWithDemo}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" disabled={isSaving}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
aria-label="Close setup wizard" aria-label="Close setup wizard"
title="Close setup wizard and use demo data"
> >
✕ ✕
</button> </button>
)}
{/* Progress Indicator */} {/* Progress Indicator */}
<div className="mb-8"> <div className="mb-8">
@@ -940,7 +1043,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div> </div>
))} ))}
</div> </div>
<div className="text-center text-sm text-gray-600 dark:text-gray-400"> <div className="text-center text-sm text-gray-600">
{currentStep === 'welcome' && 'Welcome'} {currentStep === 'welcome' && 'Welcome'}
{currentStep === 'data-source' && 'Choose Setup Method'} {currentStep === 'data-source' && 'Choose Setup Method'}
{currentStep === 'accounts' && 'Add Your Accounts'} {currentStep === 'accounts' && 'Add Your Accounts'}
+136
View File
@@ -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<Array<{ email: string; role: string }>>([]);
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 (
<div className="mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setExpanded(!expanded)}
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700"
>
<span>👥 Share Account</span>
<span>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="mt-4 space-y-4">
<form onSubmit={handleShareAccount} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={shareEmail}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Permission Level
</label>
<select
value={shareRole}
onChange={(e) => setShareRole(e.target.value as 'view' | 'edit')}
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"
>
<option value="view">View Only</option>
<option value="edit">Can Edit</option>
</select>
</div>
<button
type="submit"
disabled={loading || !shareEmail}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Sharing...' : 'Share Account'}
</button>
</form>
{message && (
<div
className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
{sharedUsers.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-900 mb-2">Shared With:</p>
<ul className="space-y-2">
{sharedUsers.map((user) => (
<li key={user.email} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{user.email}</span>
<span className="text-xs bg-gray-100 px-2 py-1 rounded capitalize">
{user.role}
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
💡 <strong>Note:</strong> Shared accounts will appear in the recipient's Capsule app. They can view or edit based on the permissions you set.
</p>
</div>
)}
</div>
);
}
+5 -5
View File
@@ -134,24 +134,24 @@ export default function TransactionEdit({ transaction, envelopes, accounts, onSa
</select> </select>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4 p-3 bg-gray-50 rounded-md">
<label className="flex items-center"> <label className="flex items-center cursor-pointer">
<input <input
type="radio" type="radio"
checked={isExpense} checked={isExpense}
onChange={() => setIsExpense(true)} onChange={() => setIsExpense(true)}
className="mr-2" className="mr-2"
/> />
<span className="text-sm text-gray-700">Expense</span> <span className="text-sm font-medium text-red-600">💸 Expense (Negative)</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center cursor-pointer">
<input <input
type="radio" type="radio"
checked={!isExpense} checked={!isExpense}
onChange={() => setIsExpense(false)} onChange={() => setIsExpense(false)}
className="mr-2" className="mr-2"
/> />
<span className="text-sm text-gray-700">Income</span> <span className="text-sm font-medium text-green-600">💰 Income (Positive)</span>
</label> </label>
</div> </div>
+30 -2
View File
@@ -7,6 +7,7 @@ export interface User {
export class AuthService { export class AuthService {
private static currentUser: User | null = null; private static currentUser: User | null = null;
private static authCallbacks: Set<(user: User | null) => void> = new Set();
static async signUp(email: string, password: string): Promise<User> { static async signUp(email: string, password: string): Promise<User> {
try { try {
@@ -31,6 +32,9 @@ export class AuthService {
localStorage.setItem('user', JSON.stringify(result.user)); localStorage.setItem('user', JSON.stringify(result.user));
} }
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user; return result.user;
} catch (error) { } catch (error) {
// Only log non-authentication errors to avoid console spam // Only log non-authentication errors to avoid console spam
@@ -65,6 +69,9 @@ export class AuthService {
localStorage.setItem('user', JSON.stringify(result.user)); localStorage.setItem('user', JSON.stringify(result.user));
} }
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user; return result.user;
} catch (error) { } catch (error) {
// Only log non-authentication errors to avoid console spam // Only log non-authentication errors to avoid console spam
@@ -81,6 +88,9 @@ export class AuthService {
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('userId'); localStorage.removeItem('userId');
} }
// Notify all listeners
this.notifyAuthChange(null);
} }
static getCurrentUser(): User | null { static getCurrentUser(): User | null {
@@ -102,6 +112,9 @@ export class AuthService {
} }
static onAuthStateChanged(callback: (user: User | null) => void): () => void { static onAuthStateChanged(callback: (user: User | null) => void): () => void {
// Add callback to listeners
this.authCallbacks.add(callback);
// Initial call with current user // Initial call with current user
const user = this.getCurrentUser(); const user = this.getCurrentUser();
callback(user); callback(user);
@@ -126,9 +139,24 @@ export class AuthService {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageChange); window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange); return () => {
window.removeEventListener('storage', handleStorageChange);
this.authCallbacks.delete(callback);
};
} }
return () => {}; return () => {
this.authCallbacks.delete(callback);
};
}
private static notifyAuthChange(user: User | null): void {
this.authCallbacks.forEach(callback => {
try {
callback(user);
} catch (err) {
console.error('Error in auth callback:', err);
}
});
} }
} }
+1
View File
@@ -26,6 +26,7 @@ interface Account {
name: string; name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan'; type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number; balance: number;
startingBalance?: number;
institution?: string; institution?: string;
accountNumber?: string; accountNumber?: string;
color: string; color: string;
+116 -27
View File
@@ -1,21 +1,33 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
import { encryptData, decryptData } from '@/lib/crypto'; import { encryptData, decryptData } from '@/lib/crypto';
const DATA_DIR = path.join(process.cwd(), 'data'); const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json'); const USERS_FILE = path.join(DATA_DIR, 'users.json');
const LOCK_DIR = path.join(DATA_DIR, 'locks');
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'; const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
// Ensure data directory exists // Initialize directories
if (!fs.existsSync(DATA_DIR)) { async function initializeDirectories() {
fs.mkdirSync(DATA_DIR, { recursive: true }); try {
} await fsPromises.mkdir(DATA_DIR, { recursive: true });
await fsPromises.mkdir(LOCK_DIR, { recursive: true });
// Ensure users file exists // Ensure users file exists
if (!fs.existsSync(USERS_FILE)) { try {
fs.writeFileSync(USERS_FILE, JSON.stringify({}), 'utf-8'); await fsPromises.access(USERS_FILE);
} catch {
await fsPromises.writeFile(USERS_FILE, JSON.stringify({}), 'utf-8');
} }
} catch (error) {
console.error('Error initializing directories:', error);
}
}
initializeDirectories();
interface UserCredentials { interface UserCredentials {
email: string; email: string;
@@ -27,8 +39,35 @@ interface Users {
[email: string]: UserCredentials; [email: string]: UserCredentials;
} }
// Lock mechanism for concurrent access prevention
class FileLock {
private static locks = new Map<string, Promise<void>>();
static async acquire(key: string): Promise<() => void> {
// Wait for any existing lock
const existingLock = this.locks.get(key);
if (existingLock) {
await existingLock;
}
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = () => {
this.locks.delete(key);
resolve();
};
});
this.locks.set(key, lockPromise);
return releaseLock!;
}
}
// Simple password hashing (in production, use bcrypt or similar) // Simple password hashing (in production, use bcrypt or similar)
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
// Prefer Web Crypto when available (browser/modern Node), otherwise fall back to Node crypto
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(password); const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashBuffer = await crypto.subtle.digest('SHA-256', data);
@@ -36,10 +75,18 @@ async function hashPassword(password: string): Promise<string> {
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} }
// Fallback for environments without crypto.subtle
return createHash('sha256').update(password, 'utf8').digest('hex');
}
export class FileStorageService { export class FileStorageService {
// User Authentication // User Authentication
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string }> { static async registerUser(email: string, password: string): Promise<{ userId: string; email: string }> {
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); const releaseLock = await FileLock.acquire('users-file');
try {
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
if (users[email]) { if (users[email]) {
throw new Error('User already exists'); throw new Error('User already exists');
@@ -54,7 +101,7 @@ export class FileStorageService {
userId, userId,
}; };
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8'); await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
// Create user data file // Create user data file
const userDataFile = path.join(DATA_DIR, `${userId}.json`); const userDataFile = path.join(DATA_DIR, `${userId}.json`);
@@ -66,13 +113,17 @@ export class FileStorageService {
}; };
const encryptedData = await encryptData(JSON.stringify(initialData), ENCRYPTION_KEY); const encryptedData = await encryptData(JSON.stringify(initialData), ENCRYPTION_KEY);
fs.writeFileSync(userDataFile, JSON.stringify({ data: encryptedData, lastUpdated: new Date().toISOString() }), 'utf-8'); await fsPromises.writeFile(userDataFile, JSON.stringify({ data: encryptedData, lastUpdated: new Date().toISOString() }), 'utf-8');
return { userId, email }; return { userId, email };
} finally {
releaseLock();
}
} }
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string }> { static async loginUser(email: string, password: string): Promise<{ userId: string; email: string }> {
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
const user = users[email]; const user = users[email];
if (!user) { if (!user) {
@@ -90,7 +141,9 @@ export class FileStorageService {
// Data Management // Data Management
static async saveUserData(userId: string, data: any): Promise<void> { static async saveUserData(userId: string, data: any): Promise<void> {
const userDataFile = path.join(DATA_DIR, `${userId}.json`); const userDataFile = path.join(DATA_DIR, `${userId}.json`);
const releaseLock = await FileLock.acquire(`user-${userId}`);
try {
// Serialize dates // Serialize dates
const serialized = { const serialized = {
...data, ...data,
@@ -106,27 +159,21 @@ export class FileStorageService {
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
fs.writeFileSync(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8'); await fsPromises.writeFile(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8');
} finally {
releaseLock();
}
} }
static async loadUserData(userId: string): Promise<any> { static async loadUserData(userId: string): Promise<any> {
const userDataFile = path.join(DATA_DIR, `${userId}.json`); const userDataFile = path.join(DATA_DIR, `${userId}.json`);
if (!fs.existsSync(userDataFile)) { try {
// Return default data for new users const fileData = JSON.parse(await fsPromises.readFile(userDataFile, 'utf-8'));
return {
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
};
}
const fileData = JSON.parse(fs.readFileSync(userDataFile, 'utf-8'));
const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY); const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY);
const parsed = JSON.parse(decryptedData); const parsed = JSON.parse(decryptedData);
// Deserialize dates // Ensure consistent date handling - always return ISO strings
return { return {
...parsed, ...parsed,
transactions: (parsed.transactions || []).map((t: any) => ({ transactions: (parsed.transactions || []).map((t: any) => ({
@@ -134,21 +181,63 @@ export class FileStorageService {
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString() date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString()
})) }))
}; };
} catch (error) {
// Return default data for new users or on read error
if ((error as any).code === 'ENOENT') {
return {
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
};
}
throw error;
}
} }
static async deleteUser(userId: string): Promise<void> { static async deleteUser(userId: string): Promise<void> {
const releaseLock = await FileLock.acquire('users-file');
try {
const userDataFile = path.join(DATA_DIR, `${userId}.json`); const userDataFile = path.join(DATA_DIR, `${userId}.json`);
if (fs.existsSync(userDataFile)) { try {
fs.unlinkSync(userDataFile); await fsPromises.unlink(userDataFile);
} catch (error) {
// File might not exist, that's ok
if ((error as any).code !== 'ENOENT') throw error;
} }
// Also remove from users.json // Also remove from users.json
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
const userEmail = Object.keys(users).find(email => users[email].userId === userId); const userEmail = Object.keys(users).find(email => users[email].userId === userId);
if (userEmail) { if (userEmail) {
delete users[userEmail]; delete users[userEmail];
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8'); await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
}
} finally {
releaseLock();
}
}
static async resetPassword(email: string, newPassword: string): Promise<void> {
const releaseLock = await FileLock.acquire('users-file');
try {
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
if (!users[email]) {
throw new Error('User not found');
}
const passwordHash = await hashPassword(newPassword);
users[email].passwordHash = passwordHash;
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
} finally {
releaseLock();
} }
} }
} }