Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover
This commit is contained in:
1 parent
caa6020ae8
commit
1b269dedd9
24 files changed
+2117
-324
No files matched your search
Generated
+48
-60
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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's set up your budget to get started with secure, organized financial tracking.</p>
|
<p className="text-gray-600 mb-4">Let'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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user