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:
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
239
frontend-web/src/components/AddAccountDialog.tsx
Normal file
239
frontend-web/src/components/AddAccountDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
141
frontend-web/src/components/ui/dialog.tsx
Normal file
141
frontend-web/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
187
frontend-web/src/components/ui/select.tsx
Normal file
187
frontend-web/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
1
frontend-web/src/fonts.d.ts
vendored
1
frontend-web/src/fonts.d.ts
vendored
@@ -1,2 +1,3 @@
|
||||
declare module '@fontsource-variable/geist';
|
||||
declare module '@fontsource-variable/oxanium';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user