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

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

No files matched your search

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