Enhance frontend-web with new features and dependencies

- Added new font support for 'Oxanium' and integrated Radix UI components including Dialog and Select.
- Updated CSS to set the default font to 'Oxanium Variable' and adjusted HTML font size.
- Introduced AddAccountDialog component for managing debt accounts, enhancing user experience.
- Refactored DebtsPage to utilize the new AddAccountDialog and improved account management features.
- Updated Redux store to support debt categories and accounts, including actions for adding, updating, and removing accounts.
- Mock data added for clients and invoices to facilitate development and testing.
This commit is contained in:
2025-12-07 11:10:33 -05:00
parent bf00261e1d
commit 043f0bd316
17 changed files with 1374 additions and 422 deletions

View File

@@ -6,7 +6,10 @@
"name": "frontend-web",
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/oxanium": "^5.2.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -162,6 +165,8 @@
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/oxanium": ["@fontsource-variable/oxanium@5.2.8", "", {}, "sha512-W3HWxRLXVB6yox3dgm1DIGOp98pz8KglwiM6/2BsMopvZrg98mXI9citFWz2pUX0FOOLwf8fmHxX+KrIn+6BoA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -180,16 +185,28 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
@@ -200,7 +217,9 @@
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
@@ -218,6 +237,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
@@ -378,6 +399,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -444,6 +467,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
@@ -498,6 +523,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
@@ -626,10 +653,16 @@
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.10.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="],
"react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recharts": ["recharts@3.5.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 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" }, "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" } }, "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
@@ -670,6 +703,8 @@
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -684,6 +719,10 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
@@ -706,20 +745,20 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
@@ -740,16 +779,6 @@
"recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
}
}

View File

@@ -11,7 +11,10 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/oxanium": "^5.2.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",

View File

@@ -0,0 +1,239 @@
import {useState} from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, useAppSelector, addAccount} from '@/store';
interface AddAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AddAccountDialog({open, onOpenChange}: AddAccountDialogProps) {
const dispatch = useAppDispatch();
const {categories} = useAppSelector(state => state.debts);
const [formData, setFormData] = useState({
name: '',
categoryId: '',
institution: '',
accountNumber: '',
originalBalance: '',
currentBalance: '',
interestRate: '',
minimumPayment: '',
dueDay: '',
notes: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const now = new Date().toISOString();
const account = {
id: crypto.randomUUID(),
name: formData.name,
categoryId: formData.categoryId,
institution: formData.institution,
accountNumber: formData.accountNumber || undefined,
originalBalance: parseFloat(formData.originalBalance) || 0,
currentBalance: parseFloat(formData.currentBalance) || parseFloat(formData.originalBalance) || 0,
interestRate: parseFloat(formData.interestRate) || 0,
minimumPayment: parseFloat(formData.minimumPayment) || 0,
dueDay: parseInt(formData.dueDay) || 1,
notes: formData.notes || undefined,
createdAt: now,
updatedAt: now,
};
dispatch(addAccount(account));
onOpenChange(false);
setFormData({
name: '',
categoryId: '',
institution: '',
accountNumber: '',
originalBalance: '',
currentBalance: '',
interestRate: '',
minimumPayment: '',
dueDay: '',
notes: '',
});
};
const updateField = (field: string, value: string) => {
setFormData(prev => ({...prev, [field]: value}));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="card-elevated max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Debt Account</DialogTitle>
<DialogDescription>
Add a new debt account to track your payoff progress
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Account Name</Label>
<Input
id="name"
placeholder="e.g., Chase Sapphire"
value={formData.name}
onChange={e => updateField('name', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category">Category</Label>
<Select
value={formData.categoryId}
onValueChange={value => updateField('categoryId', value)}
required
>
<SelectTrigger className="input-depth">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="institution">Institution / Lender</Label>
<Input
id="institution"
placeholder="e.g., Chase Bank"
value={formData.institution}
onChange={e => updateField('institution', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="accountNumber">Last 4 Digits (optional)</Label>
<Input
id="accountNumber"
placeholder="1234"
maxLength={4}
value={formData.accountNumber}
onChange={e => updateField('accountNumber', e.target.value.replace(/\D/g, ''))}
className="input-depth"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="originalBalance">Original Balance</Label>
<Input
id="originalBalance"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.originalBalance}
onChange={e => updateField('originalBalance', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currentBalance">Current Balance</Label>
<Input
id="currentBalance"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.currentBalance}
onChange={e => updateField('currentBalance', e.target.value)}
className="input-depth"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="interestRate">Interest Rate (%)</Label>
<Input
id="interestRate"
type="number"
step="0.01"
min="0"
max="100"
placeholder="0.00"
value={formData.interestRate}
onChange={e => updateField('interestRate', e.target.value)}
className="input-depth"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="minimumPayment">Min Payment</Label>
<Input
id="minimumPayment"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.minimumPayment}
onChange={e => updateField('minimumPayment', e.target.value)}
className="input-depth"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDay">Due Day of Month</Label>
<Input
id="dueDay"
type="number"
min="1"
max="31"
placeholder="1"
value={formData.dueDay}
onChange={e => updateField('dueDay', e.target.value)}
className="input-depth"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!formData.name || !formData.categoryId || !formData.institution}>
Add Account
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,53 +1,50 @@
import {NavLink, Outlet} from 'react-router-dom';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
import {TrendingUp, CreditCard, FileText, Users} from 'lucide-react';
const navItems = [
{to: '/', label: 'Net Worth', icon: '◈'},
{to: '/debts', label: 'Debts', icon: '◇'},
{to: '/invoices', label: 'Invoices', icon: '▤'},
{to: '/clients', label: 'Clients', icon: '◉'},
{to: '/', label: 'Net Worth', icon: TrendingUp},
{to: '/debts', label: 'Debts', icon: CreditCard},
{to: '/invoices', label: 'Invoices', icon: FileText},
{to: '/clients', label: 'Clients', icon: Users},
];
export default function Layout() {
return (
<TooltipProvider delayDuration={0}>
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 z-40 flex h-screen w-16 flex-col border-r border-border bg-sidebar">
<div className="flex h-16 items-center justify-center border-b border-border">
<span className="text-xl font-semibold text-foreground">W</span>
<aside className="group fixed left-0 top-0 z-40 flex h-screen w-14 hover:w-44 flex-col border-r border-border bg-sidebar transition-all duration-200 ease-out">
<div className="flex h-12 items-center gap-2 border-b border-border px-4">
<span className="text-base font-semibold text-foreground">W</span>
<span className="text-sm font-medium text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
Wealth
</span>
</div>
<nav className="flex flex-1 flex-col items-center gap-2 py-4">
<nav className="flex flex-1 flex-col gap-1 py-3 px-2">
{navItems.map(item => (
<Tooltip key={item.to}>
<TooltipTrigger asChild>
<NavLink
key={item.to}
to={item.to}
className={({isActive}) =>
`flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors ${
`flex h-9 items-center gap-3 rounded-lg px-2.5 transition-colors ${
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`
}
>
{item.icon}
</NavLink>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
{item.label}
</TooltipContent>
</Tooltip>
</span>
</NavLink>
))}
</nav>
</aside>
{/* Main content */}
<main className="ml-16 flex-1">
<main className="ml-14 flex-1">
<Outlet />
</main>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,2 +1,3 @@
declare module '@fontsource-variable/geist';
declare module '@fontsource-variable/oxanium';

View File

@@ -114,10 +114,12 @@
* {
@apply border-border outline-ring/50;
}
html {
font-size: 17px;
}
body {
@apply bg-background text-foreground;
font-family: 'Geist Variable', system-ui, sans-serif;
font-feature-settings: 'ss01' 1, 'ss02' 1;
font-family: 'Oxanium Variable', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -2,7 +2,7 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {Provider} from 'react-redux';
import {store} from './store';
import '@fontsource-variable/geist';
import '@fontsource-variable/oxanium';
import './index.css';
import App from './App.tsx';

View File

@@ -1,10 +1,4 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
@@ -14,47 +8,38 @@ export default function ClientsPage() {
const getClientStats = (clientId: string) => {
const clientInvoices = invoices.filter(i => i.clientId === clientId);
const totalBilled = clientInvoices.reduce((sum, i) => sum + i.total, 0);
const outstanding = clientInvoices
.filter(i => i.status === 'sent' || i.status === 'overdue')
.reduce((sum, i) => sum + i.total, 0);
return {totalBilled, outstanding, invoiceCount: clientInvoices.length};
const outstanding = clientInvoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0);
return {totalBilled, outstanding, count: clientInvoices.length};
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
const fmt = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Clients</h1>
<p className="text-muted-foreground">Manage your customers and clients</p>
</div>
<Button>Add Client</Button>
<div className="p-6">
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-medium">Clients</h1>
<Button size="sm">Add Client</Button>
</div>
{/* Summary */}
<div className="mb-8 grid gap-4 md:grid-cols-2">
{/* Summary Cards */}
<div className="mb-5 grid grid-cols-2 gap-4">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Clients</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Total Clients</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{clients.length}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{clients.length}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Active This Month</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">With Active Invoices</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">
{
clients.filter(c => {
const stats = getClientStats(c.id);
return stats.invoiceCount > 0;
}).length
}
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">
{clients.filter(c => getClientStats(c.id).count > 0).length}
</p>
</CardContent>
</Card>
@@ -62,46 +47,39 @@ export default function ClientsPage() {
{/* Clients List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">All Clients</CardTitle>
<CardDescription>View and manage client information</CardDescription>
<CardHeader className="pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium">All Clients</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-4 pb-3">
{clients.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No clients added yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add your first client to start creating invoices
</p>
<p className="text-muted-foreground">No clients yet</p>
<Button size="sm" className="mt-3">Add Client</Button>
</div>
) : (
<div className="space-y-2">
<div className="divide-y divide-border rounded-md border border-border">
{clients.map(client => {
const stats = getClientStats(client.id);
return (
<div
key={client.id}
className="flex items-center justify-between rounded-lg border border-border bg-secondary/30 p-4"
>
<div key={client.id} className="flex items-center justify-between px-3 py-2.5">
<div>
<p className="font-medium">{client.name}</p>
<p className="text-sm text-muted-foreground">
{client.email}
{client.company && ` · ${client.company}`}
<p className="text-sm font-medium">{client.name}</p>
<p className="text-xs text-muted-foreground">
{client.email}{client.company && ` · ${client.company}`}
</p>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<p className="text-sm text-muted-foreground">Total Billed</p>
<p className="font-medium">{formatCurrency(stats.totalBilled)}</p>
<div className="flex items-center gap-6 text-right">
<div>
<p className="text-xs text-muted-foreground">Billed</p>
<p className="text-sm font-medium">{fmt(stats.totalBilled)}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Outstanding</p>
<p className="font-medium">{formatCurrency(stats.outstanding)}</p>
<div>
<p className="text-xs text-muted-foreground">Outstanding</p>
<p className="text-sm font-medium">{fmt(stats.outstanding)}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Invoices</p>
<p className="font-medium">{stats.invoiceCount}</p>
<div>
<p className="text-xs text-muted-foreground">Invoices</p>
<p className="text-sm font-medium">{stats.count}</p>
</div>
</div>
</div>
@@ -114,4 +92,3 @@ export default function ClientsPage() {
</div>
);
}

View File

@@ -1,113 +1,253 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {useState} from 'react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import {useAppSelector, type DebtAccount} from '@/store';
import AddAccountDialog from '@/components/AddAccountDialog';
type ViewMode = 'all' | 'by-category' | 'by-account';
export default function DebtsPage() {
const {debts} = useAppSelector(state => state.debts);
const [viewMode, setViewMode] = useState<ViewMode>('by-category');
const [addDialogOpen, setAddDialogOpen] = useState(false);
const {categories, accounts} = useAppSelector(state => state.debts);
const totalDebt = debts.reduce((sum, d) => sum + d.currentBalance, 0);
const totalMinPayment = debts.reduce((sum, d) => sum + d.minimumPayment, 0);
const totalDebt = accounts.reduce((sum, a) => sum + a.currentBalance, 0);
const totalMinPayment = accounts.reduce((sum, a) => sum + a.minimumPayment, 0);
const totalOriginal = accounts.reduce((sum, a) => sum + a.originalBalance, 0);
const totalPaidDown = totalOriginal - totalDebt;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
const fmt = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
const getCategoryById = (id: string) => categories.find(c => c.id === id);
const getAccountsByCategory = (categoryId: string) => accounts.filter(a => a.categoryId === categoryId);
const getCategoryTotal = (categoryId: string) => getAccountsByCategory(categoryId).reduce((sum, a) => sum + a.currentBalance, 0);
const getProgress = (account: DebtAccount) =>
account.originalBalance > 0 ? ((account.originalBalance - account.currentBalance) / account.originalBalance) * 100 : 0;
const categoriesWithDebt = categories.filter(c => getCategoryTotal(c.id) > 0);
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Debt Management</h1>
<p className="text-muted-foreground">Track and pay down your debts</p>
<div className="p-6">
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-medium">Debts</h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm">Categories</Button>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>Add Account</Button>
</div>
<Button>Add Debt</Button>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<div className="mb-5 grid grid-cols-4 gap-4">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Debt</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Total Debt</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalDebt)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalDebt)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Monthly Minimum</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Paid Down</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalMinPayment)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalPaidDown)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Active Debts</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Monthly Min</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{debts.length}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalMinPayment)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Accounts</p>
</CardHeader>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{accounts.length}</p>
</CardContent>
</Card>
</div>
{/* Debts List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">Your Debts</CardTitle>
<CardDescription>Manage and track payments</CardDescription>
</CardHeader>
<CardContent>
{debts.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No debts added yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add your first debt to start tracking your payoff progress
</p>
</div>
) : (
<div className="space-y-4">
{debts.map(debt => {
const progress =
((debt.originalBalance - debt.currentBalance) / debt.originalBalance) * 100;
return (
<div
key={debt.id}
className="rounded-lg border border-border bg-secondary/30 p-4"
{/* View Toggle */}
<div className="mb-4 flex gap-1 rounded-lg bg-secondary p-1 w-fit">
{(['by-category', 'by-account', 'all'] as const).map(mode => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="font-medium">{debt.name}</h3>
<p className="text-sm text-muted-foreground">{debt.lender}</p>
{mode === 'by-category' ? 'By Category' : mode === 'by-account' ? 'By Account' : 'All'}
</button>
))}
</div>
<div className="text-right">
<p className="font-semibold">{formatCurrency(debt.currentBalance)}</p>
<p className="text-sm text-muted-foreground">
{debt.interestRate}% APR
</p>
{/* Content */}
{accounts.length === 0 ? (
<Card className="card-elevated">
<CardContent className="py-10 text-center">
<p className="text-muted-foreground">No debt accounts yet</p>
<Button size="sm" className="mt-3" onClick={() => setAddDialogOpen(true)}>Add Account</Button>
</CardContent>
</Card>
) : viewMode === 'by-category' ? (
<div className="space-y-4">
{categoriesWithDebt.map(category => {
const categoryAccounts = getAccountsByCategory(category.id);
const categoryTotal = getCategoryTotal(category.id);
const categoryOriginal = categoryAccounts.reduce((sum, a) => sum + a.originalBalance, 0);
const categoryProgress = categoryOriginal > 0 ? ((categoryOriginal - categoryTotal) / categoryOriginal) * 100 : 0;
return (
<Card key={category.id} className="card-elevated">
<CardHeader className="pb-2 pt-3 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-base font-medium">{category.name}</CardTitle>
<span className="text-xs text-muted-foreground">
{categoryAccounts.length} account{categoryAccounts.length !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-foreground/50" style={{width: `${categoryProgress}%`}} />
</div>
<span className="text-xs text-muted-foreground w-8">{categoryProgress.toFixed(0)}%</span>
</div>
<span className="text-base font-semibold">{fmt(categoryTotal)}</span>
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-foreground/60 transition-all"
style={{width: `${progress}%`}}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{progress.toFixed(1)}% paid off
</p>
</CardHeader>
<CardContent className="px-4 pb-3 pt-0">
<div className="divide-y divide-border rounded-md border border-border">
{categoryAccounts.map(account => (
<AccountRow key={account.id} account={account} fmt={fmt} getProgress={getProgress} />
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
) : viewMode === 'by-account' ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accounts.map(account => {
const category = getCategoryById(account.categoryId);
const progress = getProgress(account);
return (
<Card key={account.id} className="card-elevated">
<CardContent className="p-4">
<div className="mb-3 flex items-start justify-between">
<div>
<p className="font-medium">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.institution}</p>
</div>
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">{category?.name}</span>
</div>
<p className="text-2xl font-semibold">{fmt(account.currentBalance)}</p>
<div className="mt-3 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-foreground/50" style={{width: `${progress}%`}} />
</div>
<span className="text-xs text-muted-foreground">{progress.toFixed(0)}%</span>
</div>
<div className="mt-3 flex justify-between text-sm text-muted-foreground">
<span>{account.interestRate}% APR</span>
<span>{fmt(account.minimumPayment)}/mo</span>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card className="card-elevated">
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map(account => (
<AccountRow key={account.id} account={account} fmt={fmt} getProgress={getProgress} showCategory getCategoryById={getCategoryById} />
))}
</div>
</CardContent>
</Card>
)}
{/* Category Summary */}
{accounts.length > 0 && (
<Card className="card-elevated mt-5">
<CardHeader className="pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">Debt by Category</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="flex flex-wrap gap-x-6 gap-y-2">
{categories
.map(c => ({c, total: getCategoryTotal(c.id), count: getAccountsByCategory(c.id).length}))
.filter(x => x.count > 0)
.sort((a, b) => b.total - a.total)
.map(({c, total}) => (
<div key={c.id} className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-foreground/40" />
<span className="text-sm">{c.name}</span>
<span className="text-sm font-medium">{fmt(total)}</span>
<span className="text-xs text-muted-foreground">({((total / totalDebt) * 100).toFixed(0)}%)</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<AddAccountDialog open={addDialogOpen} onOpenChange={setAddDialogOpen} />
</div>
);
}
function AccountRow({
account,
fmt,
getProgress,
showCategory = false,
getCategoryById,
}: {
account: DebtAccount;
fmt: (value: number) => string;
getProgress: (account: DebtAccount) => number;
showCategory?: boolean;
getCategoryById?: (id: string) => {name: string} | undefined;
}) {
const progress = getProgress(account);
const category = getCategoryById?.(account.categoryId);
return (
<div className="flex items-center justify-between px-3 py-2.5">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{account.name}</span>
{showCategory && category && (
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">{category.name}</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{account.institution}{account.accountNumber && ` ••${account.accountNumber}`} · {account.interestRate}% APR
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-foreground/50" style={{width: `${progress}%`}} />
</div>
<span className="text-xs text-muted-foreground w-7">{progress.toFixed(0)}%</span>
</div>
<span className="text-sm font-medium w-20 text-right">{fmt(account.currentBalance)}</span>
</div>
</div>
);
}

View File

@@ -1,10 +1,4 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import {format} from 'date-fns';
@@ -12,21 +6,13 @@ import {format} from 'date-fns';
export default function InvoicesPage() {
const {invoices, clients} = useAppSelector(state => state.invoices);
const getClientName = (clientId: string) => {
const client = clients.find(c => c.id === clientId);
return client?.name ?? 'Unknown Client';
};
const getClientName = (clientId: string) => clients.find(c => c.id === clientId)?.name ?? 'Unknown';
const totalOutstanding = invoices
.filter(i => i.status === 'sent' || i.status === 'overdue')
.reduce((sum, i) => sum + i.total, 0);
const totalOutstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0);
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.total, 0);
const totalPaid = invoices
.filter(i => i.status === 'paid')
.reduce((sum, i) => sum + i.total, 0);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
const fmt = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
const statusStyles: Record<string, string> = {
draft: 'bg-muted text-muted-foreground',
@@ -37,85 +23,69 @@ export default function InvoicesPage() {
};
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Invoices</h1>
<p className="text-muted-foreground">Create and manage invoices</p>
</div>
<Button>New Invoice</Button>
<div className="p-6">
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-medium">Invoices</h1>
<Button size="sm">New Invoice</Button>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<div className="mb-5 grid grid-cols-3 gap-4">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Outstanding</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Outstanding</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalOutstanding)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalOutstanding)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Paid (All Time)</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Paid (All Time)</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalPaid)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalPaid)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Invoices</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Total Invoices</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{invoices.length}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{invoices.length}</p>
</CardContent>
</Card>
</div>
{/* Invoices List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">Recent Invoices</CardTitle>
<CardDescription>View and manage your invoices</CardDescription>
<CardHeader className="pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium">Recent Invoices</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-4 pb-3">
{invoices.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No invoices created yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Create your first invoice to get started
</p>
<p className="text-muted-foreground">No invoices yet</p>
<Button size="sm" className="mt-3">Create Invoice</Button>
</div>
) : (
<div className="space-y-2">
<div className="divide-y divide-border rounded-md border border-border">
{invoices.map(invoice => (
<div
key={invoice.id}
className="flex items-center justify-between rounded-lg border border-border bg-secondary/30 p-4"
>
<div className="flex items-center gap-4">
<div key={invoice.id} className="flex items-center justify-between px-3 py-2.5">
<div>
<p className="font-medium">{invoice.invoiceNumber}</p>
<p className="text-sm text-muted-foreground">
{getClientName(invoice.clientId)}
</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="font-semibold">{formatCurrency(invoice.total)}</p>
<p className="text-sm text-muted-foreground">
Due {format(new Date(invoice.dueDate), 'MMM d, yyyy')}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyles[invoice.status]}`}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{invoice.invoiceNumber}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium capitalize ${statusStyles[invoice.status]}`}>
{invoice.status}
</span>
</div>
<p className="text-xs text-muted-foreground">{getClientName(invoice.clientId)}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">{fmt(invoice.total)}</p>
<p className="text-xs text-muted-foreground">Due {format(new Date(invoice.dueDate), 'MMM d')}</p>
</div>
</div>
))}
</div>
@@ -125,4 +95,3 @@ export default function InvoicesPage() {
</div>
);
}

View File

@@ -1,86 +1,69 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
// Demo data for the chart
const demoData = [
{month: 'Jan', netWorth: 45000},
{month: 'Feb', netWorth: 47500},
{month: 'Mar', netWorth: 46200},
{month: 'Apr', netWorth: 52000},
{month: 'May', netWorth: 55800},
{month: 'Jun', netWorth: 58200},
];
import {AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer} from 'recharts';
import {format} from 'date-fns';
export default function NetWorthPage() {
const {assets, liabilities} = useAppSelector(state => state.netWorth);
const {assets, liabilities, snapshots} = useAppSelector(state => state.netWorth);
const chartData = snapshots.map(s => ({
month: format(new Date(s.date), 'MMM'),
netWorth: s.netWorth,
}));
const totalAssets = assets.reduce((sum, a) => sum + a.value, 0);
const totalLiabilities = liabilities.reduce((sum, l) => sum + l.balance, 0);
const netWorth = totalAssets - totalLiabilities;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
const fmt = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-medium tracking-tight">Net Worth</h1>
<p className="text-muted-foreground">Track your wealth over time</p>
<div className="p-6">
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-medium">Net Worth</h1>
<Button size="sm">Record Snapshot</Button>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<div className="mb-5 grid grid-cols-3 gap-4">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Assets</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Total Assets</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalAssets)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalAssets)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Liabilities</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Total Liabilities</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalLiabilities)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(totalLiabilities)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Net Worth</CardDescription>
<CardHeader className="pb-1 pt-3 px-4">
<p className="text-xs text-muted-foreground">Net Worth</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(netWorth)}</p>
<CardContent className="pb-3 px-4">
<p className="text-xl font-semibold">{fmt(netWorth)}</p>
</CardContent>
</Card>
</div>
{/* Chart */}
<Card className="card-elevated mb-8">
<CardHeader>
<CardTitle className="text-lg font-medium">Net Worth Over Time</CardTitle>
<Card className="card-elevated mb-5">
<CardHeader className="pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium">Net Worth Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<CardContent className="px-4 pb-3">
<div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={demoData}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(0.7 0 0)" stopOpacity={0.3} />
@@ -88,32 +71,14 @@ export default function NetWorthPage() {
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.26 0 0)" />
<XAxis
dataKey="month"
stroke="oklch(0.55 0 0)"
tick={{fill: 'oklch(0.55 0 0)'}}
/>
<YAxis
stroke="oklch(0.55 0 0)"
tick={{fill: 'oklch(0.55 0 0)'}}
tickFormatter={value => `$${value / 1000}k`}
/>
<XAxis dataKey="month" stroke="oklch(0.55 0 0)" tick={{fill: 'oklch(0.55 0 0)', fontSize: 11}} />
<YAxis stroke="oklch(0.55 0 0)" tick={{fill: 'oklch(0.55 0 0)', fontSize: 11}} tickFormatter={v => `$${v / 1000}k`} />
<Tooltip
contentStyle={{
background: 'oklch(0.18 0 0)',
border: '1px solid oklch(0.26 0 0)',
borderRadius: '8px',
}}
contentStyle={{background: 'oklch(0.18 0 0)', border: '1px solid oklch(0.26 0 0)', borderRadius: '6px', fontSize: 12}}
labelStyle={{color: 'oklch(0.92 0 0)'}}
formatter={(value: number) => [formatCurrency(value), 'Net Worth']}
/>
<Area
type="monotone"
dataKey="netWorth"
stroke="oklch(0.85 0 0)"
strokeWidth={2}
fill="url(#netWorthGradient)"
formatter={(value: number) => [fmt(value), 'Net Worth']}
/>
<Area type="monotone" dataKey="netWorth" stroke="oklch(0.85 0 0)" strokeWidth={2} fill="url(#netWorthGradient)" />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -121,55 +86,51 @@ export default function NetWorthPage() {
</Card>
{/* Assets & Liabilities */}
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<Card className="card-elevated">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-medium">Assets</CardTitle>
<CardDescription>What you own</CardDescription>
</div>
<Button variant="secondary" size="sm">
Add Asset
</Button>
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium">Assets</CardTitle>
<Button variant="secondary" size="sm">Add</Button>
</CardHeader>
<CardContent>
<CardContent className="px-4 pb-3">
{assets.length === 0 ? (
<p className="text-sm text-muted-foreground">No assets added yet</p>
<p className="text-sm text-muted-foreground py-2">No assets added yet</p>
) : (
<ul className="space-y-2">
<div className="divide-y divide-border rounded-md border border-border">
{assets.map(asset => (
<li key={asset.id} className="flex justify-between">
<span>{asset.name}</span>
<span>{formatCurrency(asset.value)}</span>
</li>
<div key={asset.id} className="flex justify-between px-3 py-2">
<div>
<span className="text-sm">{asset.name}</span>
<span className="ml-2 text-xs text-muted-foreground capitalize">{asset.type}</span>
</div>
<span className="text-sm font-medium">{fmt(asset.value)}</span>
</div>
))}
</ul>
</div>
)}
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-medium">Liabilities</CardTitle>
<CardDescription>What you owe</CardDescription>
</div>
<Button variant="secondary" size="sm">
Add Liability
</Button>
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
<CardTitle className="text-sm font-medium">Liabilities</CardTitle>
<Button variant="secondary" size="sm">Add</Button>
</CardHeader>
<CardContent>
<CardContent className="px-4 pb-3">
{liabilities.length === 0 ? (
<p className="text-sm text-muted-foreground">No liabilities added yet</p>
<p className="text-sm text-muted-foreground py-2">No liabilities added yet</p>
) : (
<ul className="space-y-2">
<div className="divide-y divide-border rounded-md border border-border">
{liabilities.map(liability => (
<li key={liability.id} className="flex justify-between">
<span>{liability.name}</span>
<span>{formatCurrency(liability.balance)}</span>
</li>
<div key={liability.id} className="flex justify-between px-3 py-2">
<div>
<span className="text-sm">{liability.name}</span>
<span className="ml-2 text-xs text-muted-foreground capitalize">{liability.type.replace('_', ' ')}</span>
</div>
<span className="text-sm font-medium">{fmt(liability.balance)}</span>
</div>
))}
</ul>
</div>
)}
</CardContent>
</Card>
@@ -177,4 +138,3 @@ export default function NetWorthPage() {
</div>
);
}

View File

@@ -28,15 +28,19 @@ export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/n
export {
setLoading as setDebtsLoading,
setError as setDebtsError,
addDebt,
updateDebt,
removeDebt,
addCategory,
updateCategory,
removeCategory,
setCategories,
addAccount,
updateAccount,
removeAccount,
setAccounts,
addPayment,
removePayment,
setDebts,
setPayments,
} from './slices/debtsSlice';
export type {Debt, DebtPayment, DebtsState} from './slices/debtsSlice';
export type {DebtCategory, DebtAccount, DebtPayment, DebtsState} from './slices/debtsSlice';
// Invoices slice
export {

View File

@@ -1,37 +1,153 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
export interface Debt {
export interface DebtCategory {
id: string;
name: string;
type: 'credit_card' | 'personal_loan' | 'auto_loan' | 'student_loan' | 'mortgage' | 'other';
color: string;
createdAt: string;
}
export interface DebtAccount {
id: string;
name: string;
categoryId: string;
institution: string;
accountNumber?: string; // last 4 digits
originalBalance: number;
currentBalance: number;
interestRate: number;
minimumPayment: number;
dueDay: number;
lender: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface DebtPayment {
id: string;
debtId: string;
accountId: string;
amount: number;
date: string;
note?: string;
}
export interface DebtsState {
debts: Debt[];
categories: DebtCategory[];
accounts: DebtAccount[];
payments: DebtPayment[];
isLoading: boolean;
error: string | null;
}
// Default categories
const defaultCategories: DebtCategory[] = [
{id: 'credit-cards', name: 'Credit Cards', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'personal-loans', name: 'Personal Loans', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'auto-loans', name: 'Auto Loans', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'student-loans', name: 'Student Loans', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'mortgage', name: 'Mortgage', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'medical', name: 'Medical', color: '#6b7280', createdAt: new Date().toISOString()},
{id: 'other', name: 'Other', color: '#6b7280', createdAt: new Date().toISOString()},
];
// Mock data for development
const mockAccounts: DebtAccount[] = [
{
id: 'cc1',
name: 'Chase Sapphire Preferred',
categoryId: 'credit-cards',
institution: 'Chase',
accountNumber: '4521',
originalBalance: 8500,
currentBalance: 3200,
interestRate: 21.99,
minimumPayment: 95,
dueDay: 15,
createdAt: '2024-01-15',
updatedAt: '2024-12-01',
},
{
id: 'cc2',
name: 'Amex Blue Cash',
categoryId: 'credit-cards',
institution: 'American Express',
accountNumber: '1008',
originalBalance: 4200,
currentBalance: 1850,
interestRate: 19.24,
minimumPayment: 55,
dueDay: 22,
createdAt: '2024-02-10',
updatedAt: '2024-12-01',
},
{
id: 'cc3',
name: 'Citi Double Cash',
categoryId: 'credit-cards',
institution: 'Citibank',
accountNumber: '7732',
originalBalance: 2800,
currentBalance: 950,
interestRate: 18.49,
minimumPayment: 35,
dueDay: 8,
createdAt: '2024-03-05',
updatedAt: '2024-12-01',
},
{
id: 'al1',
name: 'Tesla Model 3 Loan',
categoryId: 'auto-loans',
institution: 'Tesla Finance',
accountNumber: '9901',
originalBalance: 42000,
currentBalance: 15000,
interestRate: 4.99,
minimumPayment: 650,
dueDay: 1,
createdAt: '2021-06-15',
updatedAt: '2024-12-01',
},
{
id: 'sl1',
name: 'Federal Student Loan',
categoryId: 'student-loans',
institution: 'Dept of Education',
originalBalance: 45000,
currentBalance: 28000,
interestRate: 5.5,
minimumPayment: 320,
dueDay: 25,
createdAt: '2018-09-01',
updatedAt: '2024-12-01',
},
{
id: 'pl1',
name: 'Home Improvement Loan',
categoryId: 'personal-loans',
institution: 'SoFi',
accountNumber: '3344',
originalBalance: 15000,
currentBalance: 8500,
interestRate: 8.99,
minimumPayment: 285,
dueDay: 12,
createdAt: '2023-08-20',
updatedAt: '2024-12-01',
},
];
const mockPayments: DebtPayment[] = [
{id: 'p1', accountId: 'cc1', amount: 500, date: '2024-11-15', note: 'Extra payment'},
{id: 'p2', accountId: 'cc2', amount: 200, date: '2024-11-22'},
{id: 'p3', accountId: 'al1', amount: 650, date: '2024-12-01'},
{id: 'p4', accountId: 'sl1', amount: 320, date: '2024-11-25'},
];
const initialState: DebtsState = {
debts: [],
payments: [],
categories: defaultCategories,
accounts: mockAccounts,
payments: mockPayments,
isLoading: false,
error: null,
};
@@ -46,26 +162,61 @@ const debtsSlice = createSlice({
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
addDebt: (state, action: PayloadAction<Debt>) => {
state.debts.push(action.payload);
// Category actions
addCategory: (state, action: PayloadAction<DebtCategory>) => {
state.categories.push(action.payload);
},
updateDebt: (state, action: PayloadAction<Debt>) => {
const index = state.debts.findIndex(d => d.id === action.payload.id);
if (index !== -1) state.debts[index] = action.payload;
updateCategory: (state, action: PayloadAction<DebtCategory>) => {
const index = state.categories.findIndex(c => c.id === action.payload.id);
if (index !== -1) state.categories[index] = action.payload;
},
removeDebt: (state, action: PayloadAction<string>) => {
state.debts = state.debts.filter(d => d.id !== action.payload);
state.payments = state.payments.filter(p => p.debtId !== action.payload);
removeCategory: (state, action: PayloadAction<string>) => {
state.categories = state.categories.filter(c => c.id !== action.payload);
// Move accounts in this category to 'other'
state.accounts.forEach(a => {
if (a.categoryId === action.payload) a.categoryId = 'other';
});
},
setCategories: (state, action: PayloadAction<DebtCategory[]>) => {
state.categories = action.payload;
},
// Account actions
addAccount: (state, action: PayloadAction<DebtAccount>) => {
state.accounts.push(action.payload);
},
updateAccount: (state, action: PayloadAction<DebtAccount>) => {
const index = state.accounts.findIndex(a => a.id === action.payload.id);
if (index !== -1) state.accounts[index] = action.payload;
},
removeAccount: (state, action: PayloadAction<string>) => {
state.accounts = state.accounts.filter(a => a.id !== action.payload);
state.payments = state.payments.filter(p => p.accountId !== action.payload);
},
setAccounts: (state, action: PayloadAction<DebtAccount[]>) => {
state.accounts = action.payload;
},
// Payment actions
addPayment: (state, action: PayloadAction<DebtPayment>) => {
state.payments.push(action.payload);
// Update account balance
const account = state.accounts.find(a => a.id === action.payload.accountId);
if (account) {
account.currentBalance = Math.max(0, account.currentBalance - action.payload.amount);
account.updatedAt = new Date().toISOString();
}
},
removePayment: (state, action: PayloadAction<string>) => {
const payment = state.payments.find(p => p.id === action.payload);
if (payment) {
// Restore account balance
const account = state.accounts.find(a => a.id === payment.accountId);
if (account) {
account.currentBalance += payment.amount;
account.updatedAt = new Date().toISOString();
}
}
state.payments = state.payments.filter(p => p.id !== action.payload);
},
setDebts: (state, action: PayloadAction<Debt[]>) => {
state.debts = action.payload;
},
setPayments: (state, action: PayloadAction<DebtPayment[]>) => {
state.payments = action.payload;
},
@@ -75,14 +226,17 @@ const debtsSlice = createSlice({
export const {
setLoading,
setError,
addDebt,
updateDebt,
removeDebt,
addCategory,
updateCategory,
removeCategory,
setCategories,
addAccount,
updateAccount,
removeAccount,
setAccounts,
addPayment,
removePayment,
setDebts,
setPayments,
} = debtsSlice.actions;
export default debtsSlice.reducer;

View File

@@ -42,9 +42,133 @@ export interface InvoicesState {
error: string | null;
}
// Mock data for development
const mockClients: Client[] = [
{
id: 'c1',
name: 'Acme Corp',
email: 'billing@acme.com',
phone: '555-0100',
company: 'Acme Corporation',
address: '123 Business Ave, Suite 400, San Francisco, CA 94102',
createdAt: '2024-01-10',
},
{
id: 'c2',
name: 'TechStart Inc',
email: 'accounts@techstart.io',
phone: '555-0200',
company: 'TechStart Inc',
address: '456 Innovation Blvd, Austin, TX 78701',
createdAt: '2024-02-15',
},
{
id: 'c3',
name: 'Sarah Mitchell',
email: 'sarah@mitchell.design',
company: 'Mitchell Design Studio',
createdAt: '2024-03-22',
},
{
id: 'c4',
name: 'Global Media LLC',
email: 'finance@globalmedia.com',
phone: '555-0400',
company: 'Global Media LLC',
address: '789 Media Row, New York, NY 10001',
createdAt: '2024-04-08',
},
];
const mockInvoices: Invoice[] = [
{
id: 'inv1',
invoiceNumber: 'INV-2024-001',
clientId: 'c1',
status: 'paid',
issueDate: '2024-10-01',
dueDate: '2024-10-31',
lineItems: [
{id: 'li1', description: 'Web Development - October', quantity: 80, unitPrice: 150, total: 12000},
{id: 'li2', description: 'Hosting & Maintenance', quantity: 1, unitPrice: 500, total: 500},
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-10-01',
updatedAt: '2024-10-15',
},
{
id: 'inv2',
invoiceNumber: 'INV-2024-002',
clientId: 'c2',
status: 'paid',
issueDate: '2024-10-15',
dueDate: '2024-11-14',
lineItems: [
{id: 'li3', description: 'Mobile App Development', quantity: 120, unitPrice: 175, total: 21000},
],
subtotal: 21000,
tax: 0,
total: 21000,
createdAt: '2024-10-15',
updatedAt: '2024-11-10',
},
{
id: 'inv3',
invoiceNumber: 'INV-2024-003',
clientId: 'c1',
status: 'sent',
issueDate: '2024-11-01',
dueDate: '2024-12-01',
lineItems: [
{id: 'li4', description: 'Web Development - November', quantity: 60, unitPrice: 150, total: 9000},
{id: 'li5', description: 'API Integration', quantity: 20, unitPrice: 175, total: 3500},
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-11-01',
updatedAt: '2024-11-01',
},
{
id: 'inv4',
invoiceNumber: 'INV-2024-004',
clientId: 'c3',
status: 'overdue',
issueDate: '2024-10-20',
dueDate: '2024-11-20',
lineItems: [
{id: 'li6', description: 'Brand Identity Design', quantity: 1, unitPrice: 4500, total: 4500},
],
subtotal: 4500,
tax: 0,
total: 4500,
createdAt: '2024-10-20',
updatedAt: '2024-10-20',
},
{
id: 'inv5',
invoiceNumber: 'INV-2024-005',
clientId: 'c4',
status: 'draft',
issueDate: '2024-12-01',
dueDate: '2024-12-31',
lineItems: [
{id: 'li7', description: 'Video Production', quantity: 5, unitPrice: 2000, total: 10000},
{id: 'li8', description: 'Motion Graphics', quantity: 10, unitPrice: 500, total: 5000},
],
subtotal: 15000,
tax: 0,
total: 15000,
createdAt: '2024-12-01',
updatedAt: '2024-12-01',
},
];
const initialState: InvoicesState = {
clients: [],
invoices: [],
clients: mockClients,
invoices: mockInvoices,
isLoading: false,
error: null,
};

View File

@@ -32,10 +32,35 @@ export interface NetWorthState {
error: string | null;
}
// Mock data for development
const mockAssets: Asset[] = [
{id: 'a1', name: 'Chase Checking', type: 'cash', value: 12500, updatedAt: '2024-12-01'},
{id: 'a2', name: 'Ally Savings', type: 'cash', value: 35000, updatedAt: '2024-12-01'},
{id: 'a3', name: 'Fidelity 401k', type: 'investment', value: 145000, updatedAt: '2024-12-01'},
{id: 'a4', name: 'Vanguard Brokerage', type: 'investment', value: 52000, updatedAt: '2024-12-01'},
{id: 'a5', name: 'Primary Residence', type: 'property', value: 425000, updatedAt: '2024-12-01'},
{id: 'a6', name: '2021 Tesla Model 3', type: 'vehicle', value: 28000, updatedAt: '2024-12-01'},
];
const mockLiabilities: Liability[] = [
{id: 'l1', name: 'Mortgage', type: 'mortgage', balance: 320000, updatedAt: '2024-12-01'},
{id: 'l2', name: 'Auto Loan', type: 'loan', balance: 15000, updatedAt: '2024-12-01'},
{id: 'l3', name: 'Student Loans', type: 'loan', balance: 28000, updatedAt: '2024-12-01'},
];
const mockSnapshots: NetWorthSnapshot[] = [
{id: 's1', date: '2024-07-01', totalAssets: 650000, totalLiabilities: 380000, netWorth: 270000},
{id: 's2', date: '2024-08-01', totalAssets: 665000, totalLiabilities: 375000, netWorth: 290000},
{id: 's3', date: '2024-09-01', totalAssets: 680000, totalLiabilities: 370000, netWorth: 310000},
{id: 's4', date: '2024-10-01', totalAssets: 685000, totalLiabilities: 368000, netWorth: 317000},
{id: 's5', date: '2024-11-01', totalAssets: 692000, totalLiabilities: 365000, netWorth: 327000},
{id: 's6', date: '2024-12-01', totalAssets: 697500, totalLiabilities: 363000, netWorth: 334500},
];
const initialState: NetWorthState = {
assets: [],
liabilities: [],
snapshots: [],
assets: mockAssets,
liabilities: mockLiabilities,
snapshots: mockSnapshots,
isLoading: false,
error: null,
};