Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cf67f6e2a | |||
| 90d8db4abe | |||
| d1a0eedf03 | |||
| 5dbeb0cb87 | |||
| 30a7857d13 | |||
| 1e79ada6d8 | |||
| be85822cc7 | |||
| 31cffbe1bb |
Generated
+395
-2
@@ -15,7 +15,8 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1490,6 +1491,42 @@
|
|||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1503,6 +1540,12 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1820,6 +1863,69 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1882,6 +1988,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||||
@@ -2987,6 +3099,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3056,6 +3177,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -3135,6 +3377,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3482,6 +3730,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3935,6 +4193,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@@ -4509,6 +4773,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4551,6 +4825,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6386,9 +6669,31 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -6402,6 +6707,51 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6461,6 +6811,12 @@
|
|||||||
"url": "https://github.com/sponsors/remeda"
|
"url": "https://github.com/sponsors/remeda"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -7104,6 +7460,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -7450,6 +7812,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/valibot": {
|
"node_modules/valibot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||||
@@ -7464,6 +7835,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
INSERT INTO participants (name) VALUES ('Me') ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transaction_splits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
share_percent NUMERIC(5,2) NOT NULL CHECK (share_percent > 0 AND share_percent <= 100),
|
||||||
|
settled BOOLEAN DEFAULT FALSE,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (transaction_id, participant_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(transaction_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_splits_participant ON transaction_splits(participant_id);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Add email to participants for OAuth identity mapping
|
||||||
|
ALTER TABLE participants ADD COLUMN IF NOT EXISTS email TEXT UNIQUE;
|
||||||
|
|
||||||
|
-- Add owner_id and account_holder_name to statements
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS owner_id INTEGER NOT NULL DEFAULT 1 REFERENCES participants(id);
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_holder_name TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_statements_owner_id ON statements(owner_id);
|
||||||
|
|
||||||
|
-- Auto-assignment mapping table: (bank_name, account_number) -> owner
|
||||||
|
CREATE TABLE IF NOT EXISTS account_owner_mappings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bank_name TEXT NOT NULL,
|
||||||
|
account_number TEXT NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(bank_name, account_number)
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
conditions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
actions JSONB NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add FX conversion support
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS exchange_rate_to_aud NUMERIC(10,6);
|
||||||
|
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS amount_aud NUMERIC(12,2);
|
||||||
|
|
||||||
|
-- Backfill: all existing data is AUD
|
||||||
|
UPDATE transactions SET amount_aud = amount WHERE amount_aud IS NULL;
|
||||||
|
UPDATE statements SET exchange_rate_to_aud = 1.000000 WHERE exchange_rate_to_aud IS NULL;
|
||||||
@@ -66,6 +66,18 @@ model transaction_tags {
|
|||||||
@@id([transaction_id, tag_id])
|
@@id([transaction_id, tag_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model rules {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
owner_id Int
|
||||||
|
name String
|
||||||
|
conditions Json @default("[]")
|
||||||
|
actions Json @default("{}")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
priority Int @default(0)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model budgets {
|
model budgets {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
owner_id Int
|
owner_id Int
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const startStr = startDate.toISOString().slice(0, 10);
|
const startStr = startDate.toISOString().slice(0, 10);
|
||||||
const endStr = endDate.toISOString().slice(0, 10);
|
const endStr = endDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||||
const spendRows = await queryRaw<{
|
const spendRows = await queryRaw<{
|
||||||
month: string;
|
month: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -25,13 +26,20 @@ export async function GET(req: NextRequest) {
|
|||||||
`SELECT
|
`SELECT
|
||||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
COALESCE(o.category_override, t.category) as category,
|
COALESCE(o.category_override, t.category) as category,
|
||||||
SUM(t.amount)::numeric(12,2) as total_spent,
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||||
|
ELSE COALESCE(t.amount_aud, t.amount)
|
||||||
|
END
|
||||||
|
)::numeric(12,2) as total_spent,
|
||||||
COUNT(*)::int as transaction_count
|
COUNT(*)::int as transaction_count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE s.owner_id = $1
|
WHERE s.owner_id = $1
|
||||||
AND t.transaction_type = 'debit'
|
AND t.transaction_type = 'debit'
|
||||||
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND t.transaction_date < $3
|
AND t.transaction_date < $3
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2
|
||||||
@@ -39,10 +47,48 @@ export async function GET(req: NextRequest) {
|
|||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>(
|
// Income: credits/payments categorised as income
|
||||||
`SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric
|
const incomeRows = await queryRaw<{
|
||||||
FROM budgets
|
month: string;
|
||||||
WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`,
|
total_income: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('credit', 'payment')
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'income'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
|
[user.id, startStr, endStr]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Investments: any transaction categorised as investment
|
||||||
|
const investmentRows = await queryRaw<{
|
||||||
|
month: string;
|
||||||
|
total_invested: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'investment'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,50 +102,46 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const spendMap = new Map<string, number>();
|
const spendMap = new Map<string, number>();
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
const budgetMap = new Map<string, number>();
|
const incomeMap = new Map<string, number>();
|
||||||
|
const investMap = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of spendRows) {
|
for (const r of spendRows) {
|
||||||
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
||||||
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
||||||
}
|
}
|
||||||
for (const r of budgetRows) {
|
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
||||||
budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit));
|
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
||||||
}
|
|
||||||
|
|
||||||
const allCategories = new Set<string>();
|
const allCategories = new Set<string>();
|
||||||
for (const r of spendRows) allCategories.add(r.category);
|
for (const r of spendRows) allCategories.add(r.category);
|
||||||
for (const r of budgetRows) allCategories.add(r.category);
|
|
||||||
|
|
||||||
const rows = Array.from(allCategories)
|
const rows = Array.from(allCategories)
|
||||||
.sort()
|
.sort()
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const spent: Record<string, number> = {};
|
const spent: Record<string, number> = {};
|
||||||
const budget: Record<string, number> = {};
|
|
||||||
const txCount: Record<string, number> = {};
|
const txCount: Record<string, number> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
const s = spendMap.get(`${cat}:${m}`);
|
const s = spendMap.get(`${cat}:${m}`);
|
||||||
const b = budgetMap.get(`${cat}:${m}`);
|
|
||||||
const c = countMap.get(`${cat}:${m}`);
|
const c = countMap.get(`${cat}:${m}`);
|
||||||
if (s !== undefined) spent[m] = s;
|
if (s !== undefined) spent[m] = s;
|
||||||
if (b !== undefined) budget[m] = b;
|
|
||||||
if (c !== undefined) txCount[m] = c;
|
if (c !== undefined) txCount[m] = c;
|
||||||
}
|
}
|
||||||
return { category: cat, spent, budget, txCount };
|
return { category: cat, spent, txCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals: Record<string, { spent: number; budget: number }> = {};
|
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
let s = 0;
|
let spent = 0;
|
||||||
let b = 0;
|
for (const row of rows) spent += row.spent[m] || 0;
|
||||||
for (const row of rows) {
|
const income = incomeMap.get(m) || 0;
|
||||||
s += row.spent[m] || 0;
|
const investments = investMap.get(m) || 0;
|
||||||
b += row.budget[m] || 0;
|
|
||||||
}
|
|
||||||
totals[m] = {
|
totals[m] = {
|
||||||
spent: Math.round(s * 100) / 100,
|
spent: Math.round(spent * 100) / 100,
|
||||||
budget: Math.round(b * 100) / 100,
|
income: Math.round(income * 100) / 100,
|
||||||
|
investments: Math.round(investments * 100) / 100,
|
||||||
|
net: Math.round((income - spent - investments) * 100) / 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ months, rows, totals });
|
return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return NextResponse.json(user);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
interface BalanceRow {
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
total_owed: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const rows = await queryRaw<BalanceRow>(
|
||||||
|
`SELECT ts.participant_id, p.name,
|
||||||
|
SUM(t.amount * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.participant_id = $1 AND ts.settled = false
|
||||||
|
GROUP BY ts.participant_id, p.name`,
|
||||||
|
[Number(id)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getParticipantBalances } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const balances = await getParticipantBalances(user.id);
|
||||||
|
return NextResponse.json(balances);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const participants = await prisma.participants.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { name, email } = await req.json();
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const participant = await prisma.participants.create({
|
||||||
|
data: { name: name.trim(), email: email?.trim() || null },
|
||||||
|
});
|
||||||
|
return NextResponse.json(participant, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const existing = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const updated = await prisma.rules.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||||
|
...(body.actions !== undefined && { actions: body.actions }),
|
||||||
|
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||||
|
...(body.priority !== undefined && { priority: body.priority }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.rules.delete({ where: { id: Number(id) } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
|
||||||
|
interface Condition {
|
||||||
|
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
||||||
|
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
set_category?: string;
|
||||||
|
add_tag_ids?: number[];
|
||||||
|
set_merchant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TxFields {
|
||||||
|
effective_category: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
description: string;
|
||||||
|
bank_name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||||
|
if (cond.field === "amount") {
|
||||||
|
const numVal = Number(tx.amount);
|
||||||
|
const numCond = Number(cond.value);
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals": return numVal === numCond;
|
||||||
|
case "not_equals": return numVal !== numCond;
|
||||||
|
case "gt": return numVal > numCond;
|
||||||
|
case "lt": return numVal < numCond;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldVal: string;
|
||||||
|
switch (cond.field) {
|
||||||
|
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||||
|
case "description": fieldVal = tx.description || ""; break;
|
||||||
|
case "category": fieldVal = tx.effective_category || ""; break;
|
||||||
|
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strVal = fieldVal.toLowerCase();
|
||||||
|
const strCond = cond.value.toLowerCase();
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "contains": return strVal.includes(strCond);
|
||||||
|
case "equals": return strVal === strCond;
|
||||||
|
case "starts_with": return strVal.startsWith(strCond);
|
||||||
|
case "not_equals": return strVal !== strCond;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||||
|
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||||
|
|
||||||
|
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
const affectedIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const conditions = (typeof rule.conditions === "string"
|
||||||
|
? JSON.parse(rule.conditions)
|
||||||
|
: rule.conditions) as Condition[];
|
||||||
|
const actions = (typeof rule.actions === "string"
|
||||||
|
? JSON.parse(rule.actions)
|
||||||
|
: rule.actions) as Actions;
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const allMatch =
|
||||||
|
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||||
|
if (!allMatch) continue;
|
||||||
|
|
||||||
|
matched++;
|
||||||
|
affectedIds.add(tx.id);
|
||||||
|
|
||||||
|
if (actions.set_category || actions.set_merchant) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
||||||
|
category_override = COALESCE($2, transaction_overrides.category_override),
|
||||||
|
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.add_tag_ids?.length) {
|
||||||
|
for (const tagId of actions.add_tag_ids) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[tx.id, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
conditions: unknown;
|
||||||
|
actions: unknown;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||||
|
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||||
|
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
|
||||||
|
const rule = await prisma.rules.create({
|
||||||
|
data: {
|
||||||
|
owner_id: user.id,
|
||||||
|
name,
|
||||||
|
conditions: conditions ?? [],
|
||||||
|
actions: actions ?? {},
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(rule, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSharedTransactions } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const transactions = await getSharedTransactions(user.id);
|
||||||
|
return NextResponse.json(transactions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { participant_id, split_ids } = body as {
|
||||||
|
participant_id?: number;
|
||||||
|
split_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (participant_id) {
|
||||||
|
const result = await prisma.transaction_splits.updateMany({
|
||||||
|
where: { participant_id, settled: false },
|
||||||
|
data: { settled: true, settled_at: now },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ settled: result.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (split_ids?.length) {
|
||||||
|
const result = await prisma.transaction_splits.updateMany({
|
||||||
|
where: { id: { in: split_ids }, settled: false },
|
||||||
|
data: { settled: true, settled_at: now },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ settled: result.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
interface SplitInput {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitRow {
|
||||||
|
id: number;
|
||||||
|
transaction_id: number;
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
share_percent: number;
|
||||||
|
settled: boolean;
|
||||||
|
settled_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const splits = await queryRaw<SplitRow>(
|
||||||
|
`SELECT ts.*, p.name
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = $1
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[Number(id)]
|
||||||
|
);
|
||||||
|
return NextResponse.json(splits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const transactionId = Number(id);
|
||||||
|
const { splits } = (await req.json()) as { splits: SplitInput[] };
|
||||||
|
|
||||||
|
if (!splits || !Array.isArray(splits) || splits.length === 0) {
|
||||||
|
return NextResponse.json({ error: "splits array required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
|
||||||
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Shares must sum to 100%, got ${total}%` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace all splits for this transaction atomically
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
|
||||||
|
...splits.map((s) =>
|
||||||
|
prisma.transaction_splits.create({
|
||||||
|
data: {
|
||||||
|
transaction_id: transactionId,
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: s.share_percent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await queryRaw<SplitRow>(
|
||||||
|
`SELECT ts.*, p.name FROM transaction_splits ts
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = $1 ORDER BY p.name`,
|
||||||
|
[transactionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
+619
-270
@@ -2,17 +2,23 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
useBudgets,
|
ComposedChart,
|
||||||
useUpsertBudget,
|
BarChart,
|
||||||
useDeleteBudget,
|
Bar,
|
||||||
useMonthlyAnalytics,
|
Line,
|
||||||
} from "@/lib/hooks";
|
XAxis,
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
|
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||||
|
|
||||||
function formatMonth(m: string): string {
|
function currentMonthStr(): string {
|
||||||
const [year, month] = m.split("-");
|
const now = new Date();
|
||||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth(m: string): string {
|
function prevMonth(m: string): string {
|
||||||
@@ -27,66 +33,261 @@ function nextMonth(m: string): string {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentMonthStr(): string {
|
function formatMonth(m: string): string {
|
||||||
const now = new Date();
|
const [year, month] = m.split("-");
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||||
|
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function barColor(pct: number): string {
|
function formatShortMonth(m: string): string {
|
||||||
if (pct > 100) return "bg-red-500";
|
const [year, month] = m.split("-");
|
||||||
if (pct > 80) return "bg-yellow-400";
|
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||||
return "bg-emerald-500";
|
return date.toLocaleString("default", { month: "short" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPage() {
|
function fmt(n: number): string {
|
||||||
|
return `$${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtExact(n: number): string {
|
||||||
|
return `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaColor(n: number): string {
|
||||||
|
if (n > 0) return "text-red-400";
|
||||||
|
if (n < 0) return "text-emerald-400";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
background: "#18181b",
|
||||||
|
border: "1px solid #3f3f46",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
groceries: "#22c55e",
|
||||||
|
dining: "#f97316",
|
||||||
|
transport: "#06b6d4",
|
||||||
|
fuel: "#eab308",
|
||||||
|
shopping: "#ec4899",
|
||||||
|
utilities: "#8b5cf6",
|
||||||
|
entertainment: "#f43f5e",
|
||||||
|
travel: "#0ea5e9",
|
||||||
|
health: "#10b981",
|
||||||
|
insurance: "#64748b",
|
||||||
|
subscriptions: "#a78bfa",
|
||||||
|
cash_advance: "#dc2626",
|
||||||
|
government: "#78716c",
|
||||||
|
education: "#3b82f6",
|
||||||
|
rent: "#d97706",
|
||||||
|
transfers: "#6b7280",
|
||||||
|
income: "#34d399",
|
||||||
|
investment: "#818cf8",
|
||||||
|
personal_care: "#fb7185",
|
||||||
|
pets: "#86efac",
|
||||||
|
gifts: "#fcd34d",
|
||||||
|
charity: "#a3e635",
|
||||||
|
other: "#71717a",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TooltipPayload {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CashflowTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipPayload[];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload) return null;
|
||||||
|
const items = payload.filter((p) => p.value && Math.abs(p.value) > 0.01);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-36">
|
||||||
|
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
||||||
|
{items.map((item) => {
|
||||||
|
const name = item.name;
|
||||||
|
let label = name;
|
||||||
|
let valueStr = fmtExact(Number(item.value));
|
||||||
|
const color = item.fill || item.stroke || "#71717a";
|
||||||
|
|
||||||
|
if (name === "income") label = "Income";
|
||||||
|
else if (name === "savingsRate") {
|
||||||
|
label = "Savings Rate";
|
||||||
|
valueStr = `${Number(item.value).toFixed(1)}%`;
|
||||||
|
} else if (name.startsWith("cat_")) {
|
||||||
|
label = formatCategory(name.slice(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: color }} />
|
||||||
|
<span className="text-zinc-400">{label}:</span>
|
||||||
|
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{valueStr}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryPanel({
|
||||||
|
category,
|
||||||
|
selectedMonth,
|
||||||
|
}: {
|
||||||
|
category: string;
|
||||||
|
selectedMonth: string;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const updateTx = useUpdateTransaction();
|
||||||
|
|
||||||
|
const from = `${selectedMonth}-01`;
|
||||||
|
const [year, month] = selectedMonth.split("-").map(Number);
|
||||||
|
const nextDate = new Date(year, month, 1);
|
||||||
|
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
|
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
||||||
|
const txns = data?.data || [];
|
||||||
|
|
||||||
|
const handleCategoryChange = (id: number, newCategory: string) => {
|
||||||
|
updateTx.mutate(
|
||||||
|
{ id, category: newCategory },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-zinc-500 px-6 py-2">Loading transactions...</p>
|
||||||
|
) : txns.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-600 px-6 py-2">No transactions found</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-zinc-600">
|
||||||
|
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
|
||||||
|
<th className="text-left px-2 py-1 font-normal">Description</th>
|
||||||
|
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
|
||||||
|
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{txns.map((tx) => (
|
||||||
|
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
||||||
|
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">
|
||||||
|
{tx.transaction_date.slice(5).replace("-", "/")}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">
|
||||||
|
{tx.effective_merchant || tx.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">
|
||||||
|
{fmtExact(tx.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-1.5 text-right">
|
||||||
|
<select
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||||||
|
defaultValue={tx.effective_category}
|
||||||
|
onChange={(e) => handleCategoryChange(tx.id, e.target.value)}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{formatCategory(cat)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||||
const [editingCategory, setEditingCategory] = useState<string | null>(null);
|
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||||
|
|
||||||
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
|
if (isLoading || !analytics) {
|
||||||
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
|
return (
|
||||||
const upsertBudget = useUpsertBudget();
|
<div className="space-y-6">
|
||||||
const deleteBudget = useDeleteBudget();
|
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||||
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
|
</div>
|
||||||
|
);
|
||||||
// Categories with spend this month or a budget set
|
|
||||||
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
|
|
||||||
const allCategories = new Set<string>([
|
|
||||||
...currentMonthRows.map((r) => r.category),
|
|
||||||
...budgets.map((b) => b.category),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableRows = Array.from(allCategories)
|
|
||||||
.sort()
|
|
||||||
.map((cat) => {
|
|
||||||
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
|
|
||||||
const spent = analyticsRow?.spent[selectedMonth] || 0;
|
|
||||||
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
|
|
||||||
const budget = budgetMap.get(cat);
|
|
||||||
const limit = budget ? Number(budget.amount_limit) : null;
|
|
||||||
const remaining = limit !== null ? limit - spent : null;
|
|
||||||
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
|
|
||||||
return { cat, spent, txCount, budget, limit, remaining, pct };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
|
|
||||||
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
|
|
||||||
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
|
|
||||||
|
|
||||||
async function handleBudgetSave(cat: string) {
|
|
||||||
const val = parseFloat(editValue);
|
|
||||||
if (isNaN(val) || val < 0) return;
|
|
||||||
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const months = [...analytics.months].reverse(); // chronological
|
||||||
|
|
||||||
|
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
|
||||||
|
const spentDelta = totals.spent - lastTotals.spent;
|
||||||
|
|
||||||
|
// Category rows for selected month
|
||||||
|
const categoryRows = analytics.rows
|
||||||
|
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||||||
|
.map((r) => ({
|
||||||
|
category: r.category,
|
||||||
|
spent: r.spent[selectedMonth] || 0,
|
||||||
|
txCount: r.txCount[selectedMonth] || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.spent - a.spent);
|
||||||
|
|
||||||
|
const largestCategory = categoryRows[0];
|
||||||
|
|
||||||
|
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||||
|
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||||
|
|
||||||
|
// Categories that appear in expense data
|
||||||
|
const expenseCategories = analytics.rows.map((r) => r.category);
|
||||||
|
|
||||||
|
// Cashflow chart data (chronological) — per-category amounts for stacking
|
||||||
|
const cashflowData = months.map((m) => {
|
||||||
|
const entry: Record<string, unknown> = {
|
||||||
|
month: m,
|
||||||
|
label: formatShortMonth(m),
|
||||||
|
income: analytics.income[m] || 0,
|
||||||
|
};
|
||||||
|
analytics.rows.forEach((row) => {
|
||||||
|
entry[`cat_${row.category}`] = row.spent[m] || 0;
|
||||||
|
});
|
||||||
|
const income = analytics.income[m] || 0;
|
||||||
|
const spent = analytics.totals[m]?.spent || 0;
|
||||||
|
const invested = analytics.investments[m] || 0;
|
||||||
|
entry.savingsRate =
|
||||||
|
income > 0 ? Math.round(((income - spent - invested) / income) * 100) : null;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizontal category bar chart for selected month
|
||||||
|
const categoryChartData = [...categoryRows].reverse();
|
||||||
|
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Month selector */}
|
{/* Header + month selector */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Budget</h2>
|
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
||||||
@@ -94,7 +295,9 @@ export default function BudgetPage() {
|
|||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
<span className="text-sm font-medium min-w-36 text-center">
|
||||||
|
{formatMonth(selectedMonth)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
||||||
@@ -105,211 +308,294 @@ export default function BudgetPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
||||||
|
{hasIncome && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Income</p>
|
||||||
|
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">received</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
|
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
||||||
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
|
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||||||
</div>
|
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
{spentDelta === 0 || lastTotals.spent === 0 ? (
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
<span className="text-zinc-500">split-adjusted</span>
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
${totalSpent.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Budget table for current month */}
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800">
|
|
||||||
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
|
|
||||||
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{budgetsLoading || analyticsLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : tableRows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
No spending data for this month. Set a budget for any category below.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
|
`${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`
|
||||||
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
|
||||||
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{editingCategory === cat ? (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleBudgetSave(cat);
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBudgetSave(cat)}
|
|
||||||
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}}
|
|
||||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setEditValue(limit?.toString() || "");
|
|
||||||
}}
|
|
||||||
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
{limit !== null ? (
|
|
||||||
`$${limit.toFixed(2)}`
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 italic">Set...</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{budget && (
|
|
||||||
<button
|
|
||||||
onClick={() => deleteBudget.mutate(budget.id)}
|
|
||||||
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
|
|
||||||
<td
|
|
||||||
className={`px-4 py-3 text-right ${
|
|
||||||
remaining !== null
|
|
||||||
? remaining < 0
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-emerald-400"
|
|
||||||
: "text-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{pct !== null ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${barColor(pct)}`}
|
|
||||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-zinc-400 w-10 text-right">
|
|
||||||
{pct.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</p>
|
||||||
</table>
|
</div>
|
||||||
|
{(hasInvestments || totals.investments > 0) && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Invested</p>
|
||||||
|
<p className="text-2xl font-semibold text-indigo-400">{fmtExact(totals.investments)}</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">shares / ETFs</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||||||
|
{hasIncome ? (
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}
|
||||||
|
>
|
||||||
|
{totals.net >= 0 ? "+" : ""}
|
||||||
|
{fmtExact(totals.net)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
||||||
|
</>
|
||||||
|
) : largestCategory ? (
|
||||||
|
<>
|
||||||
|
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add budget for any category not yet shown */}
|
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
|
||||||
<div>
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<h3 className="text-sm font-medium">
|
||||||
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
|
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
|
||||||
<button
|
</h3>
|
||||||
key={cat}
|
{hasIncome && (
|
||||||
onClick={() => {
|
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||||||
setEditingCategory(cat);
|
<span className="flex items-center gap-1">
|
||||||
setEditValue("");
|
<span className="w-3 h-0.5 bg-amber-400 inline-block" />
|
||||||
}}
|
Savings Rate
|
||||||
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
</span>
|
||||||
>
|
<span className="flex items-center gap-1">
|
||||||
{formatCategory(cat)}
|
<span className="w-2.5 h-2.5 rounded-sm bg-emerald-500 inline-block" />
|
||||||
</button>
|
Income
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editingCategory && !allCategories.has(editingCategory) && (
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<ComposedChart
|
||||||
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
|
data={cashflowData}
|
||||||
<input
|
margin={{ top: 4, right: hasIncome ? 48 : 8, bottom: 0, left: 8 }}
|
||||||
autoFocus
|
onClick={(data) => {
|
||||||
type="number"
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
min="0"
|
const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
||||||
step="0.01"
|
if (month) setSelectedMonth(month);
|
||||||
value={editValue}
|
}}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
style={{ cursor: "pointer" }}
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === "Enter") handleBudgetSave(editingCategory);
|
<XAxis
|
||||||
if (e.key === "Escape") {
|
dataKey="label"
|
||||||
setEditingCategory(null);
|
tick={{ fill: "#71717a", fontSize: 12 }}
|
||||||
setEditValue("");
|
axisLine={false}
|
||||||
}
|
tickLine={false}
|
||||||
}}
|
|
||||||
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
|
||||||
placeholder="Budget amount"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<YAxis
|
||||||
onClick={() => handleBudgetSave(editingCategory)}
|
yAxisId="left"
|
||||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
>
|
axisLine={false}
|
||||||
Save
|
tickLine={false}
|
||||||
</button>
|
tickFormatter={(v) => `$${v}`}
|
||||||
|
width={56}
|
||||||
|
/>
|
||||||
|
{hasIncome && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
domain={[-20, 100]}
|
||||||
|
width={36}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||||
|
|
||||||
|
{/* Income bar (ungrouped, green) */}
|
||||||
|
{hasIncome && (
|
||||||
|
<Bar
|
||||||
|
yAxisId="left"
|
||||||
|
dataKey="income"
|
||||||
|
fill="#22c55e"
|
||||||
|
radius={[3, 3, 0, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
opacity={0.85}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stacked expense category bars */}
|
||||||
|
{expenseCategories.map((cat, i) => (
|
||||||
|
<Bar
|
||||||
|
key={cat}
|
||||||
|
yAxisId="left"
|
||||||
|
dataKey={`cat_${cat}`}
|
||||||
|
stackId="expenses"
|
||||||
|
fill={CATEGORY_COLORS[cat] || "#71717a"}
|
||||||
|
radius={i === expenseCategories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
>
|
||||||
|
{cashflowData.map((entry) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.month as string}
|
||||||
|
fill={
|
||||||
|
entry.month === selectedMonth
|
||||||
|
? CATEGORY_COLORS[cat] || "#71717a"
|
||||||
|
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Savings rate line */}
|
||||||
|
{hasIncome && (
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
dataKey="savingsRate"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "#fbbf24", r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Category color legend */}
|
||||||
|
{expenseCategories.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
|
||||||
|
{expenseCategories.map((cat) => (
|
||||||
|
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-sm shrink-0"
|
||||||
|
style={{ background: CATEGORY_COLORS[cat] || "#71717a" }}
|
||||||
|
/>
|
||||||
|
{formatCategory(cat)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spending by category — horizontal bar chart */}
|
||||||
|
{categoryChartData.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium mb-4">
|
||||||
|
Spending by Category — {formatMonth(selectedMonth)}
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
|
<BarChart
|
||||||
|
data={categoryChartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 0, right: 8, bottom: 0, left: 8 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${v}`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="category"
|
||||||
|
width={120}
|
||||||
|
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={formatCategory}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(label) => formatCategory(String(label))}
|
||||||
|
labelStyle={{ color: "#a1a1aa" }}
|
||||||
|
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
|
||||||
|
{categoryChartData.map((entry) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category breakdown table — with expandable rows */}
|
||||||
|
{categoryRows.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Spending Breakdown — {formatMonth(selectedMonth)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categoryRows.map(({ category, spent, txCount }) => {
|
||||||
|
const isExpanded = expandedCategory === category;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={category}
|
||||||
|
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${
|
||||||
|
isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedCategory(isExpanded ? null : category)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-sm shrink-0"
|
||||||
|
style={{ background: CATEGORY_COLORS[category] || "#71717a" }}
|
||||||
|
/>
|
||||||
|
{formatCategory(category)}
|
||||||
|
<span className="text-zinc-600 text-xs ml-1">
|
||||||
|
{isExpanded ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
||||||
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<CategoryPanel
|
||||||
|
key={`panel-${category}`}
|
||||||
|
category={category}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 6-month trend table */}
|
{/* 6-month trend table */}
|
||||||
{analytics && analytics.months.length > 0 && (
|
{analytics.months.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
||||||
@@ -322,9 +608,12 @@ export default function BudgetPage() {
|
|||||||
{analytics.months.map((m) => (
|
{analytics.months.map((m) => (
|
||||||
<th
|
<th
|
||||||
key={m}
|
key={m}
|
||||||
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
|
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${
|
||||||
|
m === selectedMonth ? "text-indigo-400" : "text-zinc-500"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedMonth(m)}
|
||||||
>
|
>
|
||||||
{m}
|
{formatShortMonth(m)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -333,45 +622,105 @@ export default function BudgetPage() {
|
|||||||
{analytics.rows.map((row) => (
|
{analytics.rows.map((row) => (
|
||||||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
||||||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
||||||
{formatCategory(row.category)}
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-sm shrink-0"
|
||||||
|
style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }}
|
||||||
|
/>
|
||||||
|
{formatCategory(row.category)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
const budget = row.budget[m];
|
|
||||||
const overBudget =
|
|
||||||
spent !== undefined && budget !== undefined && spent > budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={m}
|
key={m}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
className={`px-3 py-2 text-right tabular-nums ${
|
||||||
spent === undefined
|
spent === undefined ? "text-zinc-700" : "text-zinc-300"
|
||||||
? "text-zinc-700"
|
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||||
: overBudget
|
|
||||||
? "text-red-300 bg-red-950/40"
|
|
||||||
: "text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{hasIncome && (
|
||||||
|
<tr className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">
|
||||||
|
Income
|
||||||
|
</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const inc = analytics.income[m];
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m}
|
||||||
|
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
|
||||||
|
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inc ? fmt(inc) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{hasInvestments && (
|
||||||
|
<tr className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">
|
||||||
|
Invested
|
||||||
|
</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const inv = analytics.investments[m];
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m}
|
||||||
|
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
|
||||||
|
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inv ? fmt(inv) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
||||||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
const over = t && t.budget > 0 && t.spent > t.budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={m}
|
key={m}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
|
className={`px-3 py-2 text-right tabular-nums ${
|
||||||
|
m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
${(t?.spent || 0).toFixed(0)}
|
{fmt(t?.spent || 0)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
{hasIncome && (
|
||||||
|
<tr className="font-semibold bg-zinc-900/50">
|
||||||
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const t = analytics.totals[m];
|
||||||
|
const net = t?.net || 0;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m}
|
||||||
|
className={`px-3 py-2 text-right tabular-nums ${
|
||||||
|
net >= 0 ? "text-emerald-400" : "text-red-400"
|
||||||
|
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||||
|
>
|
||||||
|
{net >= 0 ? "+" : ""}
|
||||||
|
{fmt(net)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+324
-3
@@ -1,8 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ value: "merchant_normalized", label: "Merchant" },
|
||||||
|
{ value: "description", label: "Description" },
|
||||||
|
{ value: "category", label: "Category" },
|
||||||
|
{ value: "bank_name", label: "Bank" },
|
||||||
|
{ value: "amount", label: "Amount" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TEXT_OPS = [
|
||||||
|
{ value: "contains", label: "contains" },
|
||||||
|
{ value: "equals", label: "equals" },
|
||||||
|
{ value: "starts_with", label: "starts with" },
|
||||||
|
{ value: "not_equals", label: "not equals" },
|
||||||
|
];
|
||||||
|
const AMOUNT_OPS = [
|
||||||
|
{ value: "equals", label: "=" },
|
||||||
|
{ value: "not_equals", label: "≠" },
|
||||||
|
{ value: "gt", label: ">" },
|
||||||
|
{ value: "lt", label: "<" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Condition = { field: string; operator: string; value: string };
|
||||||
|
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||||
|
|
||||||
|
function humanCondition(c: Condition): string {
|
||||||
|
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||||
|
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
||||||
|
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
||||||
|
return `${fieldLabel} ${opText} "${c.value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`);
|
||||||
|
if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`);
|
||||||
|
if (a.add_tag_ids?.length) {
|
||||||
|
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||||
|
parts.push(`add tags: ${names}`);
|
||||||
|
}
|
||||||
|
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
||||||
|
}
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
|
const { data: rules = [], isLoading } = useRules();
|
||||||
|
const { data: tags = [] } = useTags();
|
||||||
|
const createRule = useCreateRule();
|
||||||
|
const updateRule = useUpdateRule();
|
||||||
|
const deleteRule = useDeleteRule();
|
||||||
|
const applyRules = useApplyRules();
|
||||||
|
|
||||||
|
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||||
|
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
|
const [actions, setActions] = useState<Actions>({});
|
||||||
|
const [priority, setPriority] = useState(0);
|
||||||
|
|
||||||
|
function addCondition() {
|
||||||
|
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(i: number, patch: Partial<Condition>) {
|
||||||
|
setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(i: number) {
|
||||||
|
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
||||||
|
setName("");
|
||||||
|
setConditions([]);
|
||||||
|
setActions({});
|
||||||
|
setPriority(0);
|
||||||
|
setShowForm(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
const result = await applyRules.mutateAsync();
|
||||||
|
setApplyResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
<h2 className="text-xl font-semibold">Rules</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applyRules.isPending}
|
||||||
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "New Rule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{applyResult && (
|
||||||
|
<div className="p-4 bg-emerald-900/30 border border-emerald-700 rounded-lg text-sm">
|
||||||
|
Applied: <strong>{applyResult.matched}</strong> condition matches across{" "}
|
||||||
|
<strong>{applyResult.transactions_affected}</strong> transactions.
|
||||||
|
<button onClick={() => setApplyResult(null)} className="ml-4 text-zinc-400 hover:text-white">
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">New Rule</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Tag Woolworths as groceries"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs text-zinc-500">Conditions (ALL must match)</label>
|
||||||
|
<button type="button" onClick={addCondition} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
+ Add condition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{conditions.map((cond, i) => {
|
||||||
|
const isAmount = cond.field === "amount";
|
||||||
|
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||||
|
<select
|
||||||
|
value={cond.field}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateCondition(i, {
|
||||||
|
field: e.target.value,
|
||||||
|
operator: e.target.value === "amount" ? "equals" : "contains",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{ops.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||||
|
placeholder="value"
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCondition(i)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conditions.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600">No conditions — rule will match ALL transactions.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Category (optional)</label>
|
||||||
|
<select
|
||||||
|
value={actions.set_category || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_category: e.target.value || undefined })}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— no change —</option>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{formatCategory(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Merchant (optional)</label>
|
||||||
|
<input
|
||||||
|
value={actions.set_merchant || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_merchant: e.target.value || undefined })}
|
||||||
|
placeholder="Normalized name"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Add Tags (optional)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = (actions.add_tag_ids || []).includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const ids = actions.add_tag_ids || [];
|
||||||
|
setActions({
|
||||||
|
...actions,
|
||||||
|
add_tag_ids: selected ? ids.filter((id) => id !== tag.id) : [...ids, tag.id],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
||||||
|
selected ? "border-transparent text-white" : "border-zinc-700 text-zinc-400"
|
||||||
|
}`}
|
||||||
|
style={selected ? { backgroundColor: tag.color } : {}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tags.length === 0 && <p className="text-xs text-zinc-600">No tags created yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(Number(e.target.value))}
|
||||||
|
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createRule.isPending}
|
||||||
|
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createRule.isPending ? "Creating..." : "Create Rule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">No rules yet. Create one to auto-classify transactions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
||||||
|
const acts =
|
||||||
|
rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {};
|
||||||
|
return (
|
||||||
|
<div key={rule.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="font-medium text-sm">{rule.name}</span>
|
||||||
|
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||||
|
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||||
|
rule.enabled ? "bg-indigo-600" : "bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
|
||||||
|
rule.enabled ? "translate-x-4" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-3
@@ -1,8 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
useSharedTransactions,
|
||||||
|
useParticipantBalances,
|
||||||
|
useSettleSplits,
|
||||||
|
} from "@/lib/hooks";
|
||||||
|
import type { SharedTransactionRow } from "@/lib/queries";
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(n: number) {
|
||||||
|
return `$${Number(n).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SharedPage() {
|
export default function SharedPage() {
|
||||||
|
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions();
|
||||||
|
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
|
||||||
|
const settle = useSettleSplits();
|
||||||
|
const [settling, setSettling] = useState<number | null>(null);
|
||||||
|
|
||||||
|
async function handleSettleParticipant(participantId: number) {
|
||||||
|
setSettling(participantId);
|
||||||
|
await settle.mutateAsync({ participant_id: participantId });
|
||||||
|
setSettling(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const others = balances.filter((b) => b.name !== "Me");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
|
||||||
|
{/* Balance summary */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{balLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||||||
|
) : (
|
||||||
|
others.map((b) => (
|
||||||
|
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{b.name}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{b.unsettled_count} unsettled</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-semibold text-amber-400">{formatAmount(b.total_owed)}</p>
|
||||||
|
<p className="text-xs text-zinc-500">owes you</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{b.unsettled_count > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSettleParticipant(b.id)}
|
||||||
|
disabled={settling === b.id}
|
||||||
|
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{settling === b.id ? "Settling..." : "Mark All Settled"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction list */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||||
|
</div>
|
||||||
|
{txLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||||
|
No split transactions yet. Use the Split button on any transaction.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||||
|
<th className="px-4 py-2 text-xs text-zinc-500 font-medium">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||||
|
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||||
|
const unsettled = splits.filter((s) => !s.settled && s.name !== "Me");
|
||||||
|
return (
|
||||||
|
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
|
{formatDate(tx.transaction_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
|
||||||
|
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium tabular-nums">
|
||||||
|
{formatAmount(tx.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{splits.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.participant_id}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
|
||||||
|
s.settled
|
||||||
|
? "bg-zinc-800 text-zinc-500"
|
||||||
|
: "bg-amber-900/40 text-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.name} {s.share_percent}%
|
||||||
|
{s.settled && <span className="text-emerald-500">✓</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{unsettled.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
settle.mutateAsync({
|
||||||
|
split_ids: unsettled.map((s) => (s as unknown as { split_id: number }).split_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={settle.isPending}
|
||||||
|
className="text-xs px-2 py-1 bg-emerald-800 hover:bg-emerald-700 text-white rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Settle
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||||
{ href: "/shared", label: "Shared", icon: "users" },
|
{ href: "/shared", label: "Shared", icon: "users" },
|
||||||
{ href: "/budget", label: "Budget", icon: "bar-chart" },
|
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction } from "@/lib/hooks";
|
||||||
|
|
||||||
|
interface Split {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactionId?: number;
|
||||||
|
transactionIds?: number[];
|
||||||
|
amount?: number;
|
||||||
|
description: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitModal({ transactionId, transactionIds, amount, description, onClose }: Props) {
|
||||||
|
const isBulk = !!transactionIds && transactionIds.length > 0;
|
||||||
|
const singleId = transactionId ?? 0;
|
||||||
|
|
||||||
|
const { data: participants } = useParticipants();
|
||||||
|
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
|
||||||
|
const setSplits = useSetSplits();
|
||||||
|
const bulkAction = useBulkAction();
|
||||||
|
|
||||||
|
const [splits, setSplitsState] = useState<Split[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Initialise: bulk always defaults to 100% Me; single loads existing splits
|
||||||
|
useEffect(() => {
|
||||||
|
if (!participants || participants.length === 0) return;
|
||||||
|
const me = participants.find((p) => p.name === "Me");
|
||||||
|
if (isBulk) {
|
||||||
|
if (me) setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
|
||||||
|
} else if (existingSplits && existingSplits.length > 0) {
|
||||||
|
setSplitsState(
|
||||||
|
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: Number(s.share_percent),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else if (me) {
|
||||||
|
setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
|
||||||
|
}
|
||||||
|
}, [existingSplits, participants, isBulk]);
|
||||||
|
|
||||||
|
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
||||||
|
|
||||||
|
const toggleParticipant = (id: number) => {
|
||||||
|
setSplitsState((prev) => {
|
||||||
|
const exists = prev.find((s) => s.participant_id === id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter((s) => s.participant_id !== id);
|
||||||
|
}
|
||||||
|
// Add with equal split
|
||||||
|
const count = prev.length + 1;
|
||||||
|
const equal = Math.floor(100 / count);
|
||||||
|
const remainder = 100 - equal * count;
|
||||||
|
return [
|
||||||
|
...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })),
|
||||||
|
{ participant_id: id, share_percent: equal },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShare = (id: number, value: number) => {
|
||||||
|
setSplitsState((prev) =>
|
||||||
|
prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitEvenly = () => {
|
||||||
|
if (splits.length === 0) return;
|
||||||
|
const each = Math.floor(100 / splits.length);
|
||||||
|
const remainder = 100 - each * splits.length;
|
||||||
|
setSplitsState((prev) =>
|
||||||
|
prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = isBulk ? bulkAction.isPending : setSplits.isPending;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError("");
|
||||||
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
|
setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isBulk) {
|
||||||
|
await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits });
|
||||||
|
} else {
|
||||||
|
await setSplits.mutateAsync({ transactionId: singleId, splits });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save splits");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md mx-4 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">
|
||||||
|
{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-400 mb-4 truncate">{description}</p>
|
||||||
|
{!isBulk && amount !== undefined && (
|
||||||
|
<p className="text-2xl font-mono font-semibold mb-6">
|
||||||
|
${Number(amount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Participant toggles */}
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{participants?.map((p) => {
|
||||||
|
const split = splits.find((s) => s.participant_id === p.id);
|
||||||
|
const active = !!split;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleParticipant(p.id)}
|
||||||
|
className={`w-8 h-8 rounded-full text-sm font-medium flex-shrink-0 transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-zinc-800 text-zinc-500 hover:bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.name.charAt(0).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<span className="flex-1 text-sm">{p.name}</span>
|
||||||
|
{active && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
value={split.share_percent}
|
||||||
|
onChange={(e) => updateShare(p.id, Number(e.target.value))}
|
||||||
|
className="w-24 accent-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-right text-sm font-mono">
|
||||||
|
{split.share_percent}%
|
||||||
|
</span>
|
||||||
|
{!isBulk && amount !== undefined && (
|
||||||
|
<span className="w-20 text-right text-sm text-zinc-400 font-mono">
|
||||||
|
${((amount * split.share_percent) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-4 text-sm">
|
||||||
|
<span className={`font-mono ${Math.abs(total - 100) > 0.01 ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
Total: {total.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={splitEvenly}
|
||||||
|
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
Split evenly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPending || Math.abs(total - 100) > 0.01}
|
||||||
|
className="flex-1 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving..." : "Save splits"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { queryRaw } from "./db";
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(req: NextRequest): Promise<CurrentUser | null> {
|
||||||
|
const email = req.headers.get("x-forwarded-user");
|
||||||
|
|
||||||
|
// Dev fallback: no Traefik header → use participant id=1
|
||||||
|
if (!email) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const rows = await queryRaw<CurrentUser>(`SELECT id, name, email FROM participants WHERE id = 1`);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await queryRaw<CurrentUser>(
|
||||||
|
`SELECT id, name, COALESCE(email, '') as email FROM participants WHERE email = $1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const CATEGORIES = [
|
|||||||
"rent",
|
"rent",
|
||||||
"transfers",
|
"transfers",
|
||||||
"income",
|
"income",
|
||||||
|
"investment",
|
||||||
"personal_care",
|
"personal_care",
|
||||||
"pets",
|
"pets",
|
||||||
"gifts",
|
"gifts",
|
||||||
|
|||||||
+82
-1
@@ -353,6 +353,85 @@ export function useCreateParticipant() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rules ---
|
||||||
|
|
||||||
|
export interface RuleRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
conditions: { field: string; operator: string; value: string }[];
|
||||||
|
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRules() {
|
||||||
|
return useQuery<RuleRow[]>({
|
||||||
|
queryKey: ["rules"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/rules");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => {
|
||||||
|
const res = await fetch("/api/rules", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
|
||||||
|
const res = await fetch(`/api/rules/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await fetch(`/api/rules/${id}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApplyRules() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error("Failed to apply rules");
|
||||||
|
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Budgets & Analytics ---
|
// --- Budgets & Analytics ---
|
||||||
|
|
||||||
export interface BudgetRow {
|
export interface BudgetRow {
|
||||||
@@ -372,7 +451,9 @@ export interface MonthlyAnalyticsRow {
|
|||||||
export interface MonthlyAnalytics {
|
export interface MonthlyAnalytics {
|
||||||
months: string[];
|
months: string[];
|
||||||
rows: MonthlyAnalyticsRow[];
|
rows: MonthlyAnalyticsRow[];
|
||||||
totals: Record<string, { spent: number; budget: number }>;
|
income: Record<string, number>;
|
||||||
|
investments: Record<string, number>;
|
||||||
|
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBudgets(month: string) {
|
export function useBudgets(month: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user