From bf00261e1da6209f9be2abc06ecb3c2b0fbe193d Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 7 Dec 2025 10:49:43 -0500 Subject: [PATCH] Update frontend-web dependencies and implement routing structure - Added new dependencies: @fontsource-variable/geist, @radix-ui/react-separator, @radix-ui/react-tooltip, date-fns, react-router-dom, and recharts. - Updated index.html to set the HTML class to "dark" for dark mode support. - Refactored App component to implement routing with React Router, replacing the previous UI structure with a layout and multiple pages (NetWorth, Debts, Invoices, Clients). - Enhanced CSS for dark mode and added depth utilities for improved UI aesthetics. - Expanded Redux store to include net worth, debts, and invoices slices for comprehensive state management. --- frontend-web/bun.lock | 146 ++++++++++++++ frontend-web/index.html | 2 +- frontend-web/package.json | 6 + frontend-web/src/App.tsx | 35 ++-- frontend-web/src/components/Layout.tsx | 53 ++++++ frontend-web/src/components/ui/separator.tsx | 26 +++ frontend-web/src/components/ui/tooltip.tsx | 61 ++++++ frontend-web/src/fonts.d.ts | 2 + frontend-web/src/index.css | 107 ++++++++--- frontend-web/src/main.tsx | 1 + frontend-web/src/pages/ClientsPage.tsx | 117 ++++++++++++ frontend-web/src/pages/DebtsPage.tsx | 113 +++++++++++ frontend-web/src/pages/InvoicesPage.tsx | 128 +++++++++++++ frontend-web/src/pages/NetWorthPage.tsx | 180 ++++++++++++++++++ frontend-web/src/store/index.ts | 49 ++++- frontend-web/src/store/slices/debtsSlice.ts | 88 +++++++++ .../src/store/slices/invoicesSlice.ts | 118 ++++++++++++ .../src/store/slices/netWorthSlice.ts | 96 ++++++++++ frontend-web/src/store/store.ts | 10 +- 19 files changed, 1283 insertions(+), 55 deletions(-) create mode 100644 frontend-web/src/components/Layout.tsx create mode 100644 frontend-web/src/components/ui/separator.tsx create mode 100644 frontend-web/src/components/ui/tooltip.tsx create mode 100644 frontend-web/src/fonts.d.ts create mode 100644 frontend-web/src/pages/ClientsPage.tsx create mode 100644 frontend-web/src/pages/DebtsPage.tsx create mode 100644 frontend-web/src/pages/InvoicesPage.tsx create mode 100644 frontend-web/src/pages/NetWorthPage.tsx create mode 100644 frontend-web/src/store/slices/debtsSlice.ts create mode 100644 frontend-web/src/store/slices/invoicesSlice.ts create mode 100644 frontend-web/src/store/slices/netWorthSlice.ts diff --git a/frontend-web/bun.lock b/frontend-web/bun.lock index 79f054d..c5ecde0 100644 --- a/frontend-web/bun.lock +++ b/frontend-web/bun.lock @@ -5,15 +5,21 @@ "": { "name": "frontend-web", "dependencies": { + "@fontsource-variable/geist": "^5.2.8", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^2.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.556.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", + "react-router-dom": "^7.10.1", + "recharts": "^3.5.1", "tailwind-merge": "^3.4.0", }, "devDependencies": { @@ -146,6 +152,16 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], + "@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=="], @@ -164,14 +180,52 @@ "@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/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-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-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-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=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@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-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "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-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@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-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@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-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=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "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-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@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", "@radix-ui/react-visually-hidden": "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-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "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-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@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-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=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "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-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], @@ -262,6 +316,24 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -334,12 +406,40 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -348,6 +448,8 @@ "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "es-toolkit": ["es-toolkit@1.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -374,6 +476,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -416,6 +520,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -514,10 +620,18 @@ "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "react-is": ["react-is@19.2.1", "", {}, "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA=="], + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "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=="], + + "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=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], @@ -532,6 +646,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -548,6 +664,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], @@ -568,6 +686,8 @@ "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=="], + "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -586,6 +706,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-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-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-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-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-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=="], @@ -604,6 +738,18 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "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=="], } } diff --git a/frontend-web/index.html b/frontend-web/index.html index cbd540f..111c234 100644 --- a/frontend-web/index.html +++ b/frontend-web/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend-web/package.json b/frontend-web/package.json index 93a89d6..c64b8df 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -10,15 +10,21 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/geist": "^5.2.8", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^2.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.556.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", + "react-router-dom": "^7.10.1", + "recharts": "^3.5.1", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 1ef9837..9adbba6 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,24 +1,21 @@ -import {Button} from '@/components/ui/button'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; -import {Input} from '@/components/ui/input'; -import {Label} from '@/components/ui/label'; +import {BrowserRouter, Routes, Route} from 'react-router-dom'; +import Layout from '@/components/Layout'; +import NetWorthPage from '@/pages/NetWorthPage'; +import DebtsPage from '@/pages/DebtsPage'; +import InvoicesPage from '@/pages/InvoicesPage'; +import ClientsPage from '@/pages/ClientsPage'; export default function App() { return ( -
- - - Personal Finances - Track your income and expenses - - -
- - -
- -
-
-
+ + + }> + } /> + } /> + } /> + } /> + + + ); } diff --git a/frontend-web/src/components/Layout.tsx b/frontend-web/src/components/Layout.tsx new file mode 100644 index 0000000..0338c24 --- /dev/null +++ b/frontend-web/src/components/Layout.tsx @@ -0,0 +1,53 @@ +import {NavLink, Outlet} from 'react-router-dom'; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; + +const navItems = [ + {to: '/', label: 'Net Worth', icon: '◈'}, + {to: '/debts', label: 'Debts', icon: '◇'}, + {to: '/invoices', label: 'Invoices', icon: '▤'}, + {to: '/clients', label: 'Clients', icon: '◉'}, +]; + +export default function Layout() { + return ( + +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+
+ ); +} + diff --git a/frontend-web/src/components/ui/separator.tsx b/frontend-web/src/components/ui/separator.tsx new file mode 100644 index 0000000..bb3ad74 --- /dev/null +++ b/frontend-web/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/frontend-web/src/components/ui/tooltip.tsx b/frontend-web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..a4e90d4 --- /dev/null +++ b/frontend-web/src/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend-web/src/fonts.d.ts b/frontend-web/src/fonts.d.ts new file mode 100644 index 0000000..e1c167a --- /dev/null +++ b/frontend-web/src/fonts.d.ts @@ -0,0 +1,2 @@ +declare module '@fontsource-variable/geist'; + diff --git a/frontend-web/src/index.css b/frontend-web/src/index.css index d3f10e6..3f2358e 100644 --- a/frontend-web/src/index.css +++ b/frontend-web/src/index.css @@ -77,37 +77,37 @@ } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.14 0 0); + --foreground: oklch(0.92 0 0); + --card: oklch(0.18 0 0); + --card-foreground: oklch(0.92 0 0); + --popover: oklch(0.18 0 0); + --popover-foreground: oklch(0.92 0 0); + --primary: oklch(0.92 0 0); + --primary-foreground: oklch(0.14 0 0); + --secondary: oklch(0.22 0 0); + --secondary-foreground: oklch(0.85 0 0); + --muted: oklch(0.22 0 0); + --muted-foreground: oklch(0.55 0 0); + --accent: oklch(0.22 0 0); + --accent-foreground: oklch(0.92 0 0); + --destructive: oklch(0.6 0.2 25); + --border: oklch(0.26 0 0); + --input: oklch(0.2 0 0); + --ring: oklch(0.45 0 0); + --chart-1: oklch(0.7 0 0); + --chart-2: oklch(0.55 0 0); + --chart-3: oklch(0.8 0 0); + --chart-4: oklch(0.45 0 0); + --chart-5: oklch(0.65 0 0); + --sidebar: oklch(0.12 0 0); + --sidebar-foreground: oklch(0.92 0 0); + --sidebar-primary: oklch(0.92 0 0); + --sidebar-primary-foreground: oklch(0.14 0 0); + --sidebar-accent: oklch(0.22 0 0); + --sidebar-accent-foreground: oklch(0.92 0 0); + --sidebar-border: oklch(0.26 0 0); + --sidebar-ring: oklch(0.45 0 0); } @layer base { @@ -116,5 +116,50 @@ } body { @apply bg-background text-foreground; + font-family: 'Geist Variable', system-ui, sans-serif; + font-feature-settings: 'ss01' 1, 'ss02' 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } } + +/* Depth utilities */ +.dark body { + background: + radial-gradient(ellipse 80% 60% at 50% -20%, oklch(0.22 0 0), transparent), + oklch(0.14 0 0); +} + +.card-elevated { + background: linear-gradient( + 170deg, + oklch(0.2 0 0) 0%, + oklch(0.17 0 0) 100% + ); + border: 1px solid oklch(1 0 0 / 0.06); + box-shadow: + inset 0 1px 0 oklch(1 0 0 / 0.03), + 0 2px 8px oklch(0 0 0 / 0.3), + 0 8px 32px oklch(0 0 0 / 0.25); +} + +.glow-subtle { + box-shadow: + inset 0 1px 0 oklch(1 0 0 / 0.03), + 0 2px 8px oklch(0 0 0 / 0.3), + 0 8px 32px oklch(0 0 0 / 0.25); +} + +.input-depth { + background: oklch(0.12 0 0); + border: 1px solid oklch(1 0 0 / 0.08); + box-shadow: inset 0 1px 2px oklch(0 0 0 / 0.3); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.input-depth:focus { + border-color: oklch(1 0 0 / 0.2); + box-shadow: + inset 0 1px 2px oklch(0 0 0 / 0.3), + 0 0 0 3px oklch(1 0 0 / 0.05); +} diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index 1a8083c..99c4fb9 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -2,6 +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 './index.css'; import App from './App.tsx'; diff --git a/frontend-web/src/pages/ClientsPage.tsx b/frontend-web/src/pages/ClientsPage.tsx new file mode 100644 index 0000000..d907541 --- /dev/null +++ b/frontend-web/src/pages/ClientsPage.tsx @@ -0,0 +1,117 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import {Button} from '@/components/ui/button'; +import {useAppSelector} from '@/store'; + +export default function ClientsPage() { + const {clients, invoices} = useAppSelector(state => state.invoices); + + 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 formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value); + + return ( +
+
+
+

Clients

+

Manage your customers and clients

+
+ +
+ + {/* Summary */} +
+ + + Total Clients + + +

{clients.length}

+
+
+ + + Active This Month + + +

+ { + clients.filter(c => { + const stats = getClientStats(c.id); + return stats.invoiceCount > 0; + }).length + } +

+
+
+
+ + {/* Clients List */} + + + All Clients + View and manage client information + + + {clients.length === 0 ? ( +
+

No clients added yet

+

+ Add your first client to start creating invoices +

+
+ ) : ( +
+ {clients.map(client => { + const stats = getClientStats(client.id); + return ( +
+
+

{client.name}

+

+ {client.email} + {client.company && ` · ${client.company}`} +

+
+
+
+

Total Billed

+

{formatCurrency(stats.totalBilled)}

+
+
+

Outstanding

+

{formatCurrency(stats.outstanding)}

+
+
+

Invoices

+

{stats.invoiceCount}

+
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} + diff --git a/frontend-web/src/pages/DebtsPage.tsx b/frontend-web/src/pages/DebtsPage.tsx new file mode 100644 index 0000000..80ae4b9 --- /dev/null +++ b/frontend-web/src/pages/DebtsPage.tsx @@ -0,0 +1,113 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import {Button} from '@/components/ui/button'; +import {useAppSelector} from '@/store'; + +export default function DebtsPage() { + const {debts} = 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 formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value); + + return ( +
+
+
+

Debt Management

+

Track and pay down your debts

+
+ +
+ + {/* Summary Cards */} +
+ + + Total Debt + + +

{formatCurrency(totalDebt)}

+
+
+ + + Monthly Minimum + + +

{formatCurrency(totalMinPayment)}

+
+
+ + + Active Debts + + +

{debts.length}

+
+
+
+ + {/* Debts List */} + + + Your Debts + Manage and track payments + + + {debts.length === 0 ? ( +
+

No debts added yet

+

+ Add your first debt to start tracking your payoff progress +

+
+ ) : ( +
+ {debts.map(debt => { + const progress = + ((debt.originalBalance - debt.currentBalance) / debt.originalBalance) * 100; + return ( +
+
+
+

{debt.name}

+

{debt.lender}

+
+
+

{formatCurrency(debt.currentBalance)}

+

+ {debt.interestRate}% APR +

+
+
+
+
+
+

+ {progress.toFixed(1)}% paid off +

+
+ ); + })} +
+ )} + + +
+ ); +} + diff --git a/frontend-web/src/pages/InvoicesPage.tsx b/frontend-web/src/pages/InvoicesPage.tsx new file mode 100644 index 0000000..58ed59b --- /dev/null +++ b/frontend-web/src/pages/InvoicesPage.tsx @@ -0,0 +1,128 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import {Button} from '@/components/ui/button'; +import {useAppSelector} from '@/store'; +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 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 formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value); + + const statusStyles: Record = { + draft: 'bg-muted text-muted-foreground', + sent: 'bg-blue-500/10 text-blue-400', + paid: 'bg-green-500/10 text-green-400', + overdue: 'bg-red-500/10 text-red-400', + cancelled: 'bg-muted text-muted-foreground line-through', + }; + + return ( +
+
+
+

Invoices

+

Create and manage invoices

+
+ +
+ + {/* Summary Cards */} +
+ + + Outstanding + + +

{formatCurrency(totalOutstanding)}

+
+
+ + + Paid (All Time) + + +

{formatCurrency(totalPaid)}

+
+
+ + + Total Invoices + + +

{invoices.length}

+
+
+
+ + {/* Invoices List */} + + + Recent Invoices + View and manage your invoices + + + {invoices.length === 0 ? ( +
+

No invoices created yet

+

+ Create your first invoice to get started +

+
+ ) : ( +
+ {invoices.map(invoice => ( +
+
+
+

{invoice.invoiceNumber}

+

+ {getClientName(invoice.clientId)} +

+
+
+
+
+

{formatCurrency(invoice.total)}

+

+ Due {format(new Date(invoice.dueDate), 'MMM d, yyyy')} +

+
+ + {invoice.status} + +
+
+ ))} +
+ )} +
+
+
+ ); +} + diff --git a/frontend-web/src/pages/NetWorthPage.tsx b/frontend-web/src/pages/NetWorthPage.tsx new file mode 100644 index 0000000..6604ad4 --- /dev/null +++ b/frontend-web/src/pages/NetWorthPage.tsx @@ -0,0 +1,180 @@ +import { + Card, + CardContent, + CardDescription, + 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}, +]; + +export default function NetWorthPage() { + const {assets, liabilities} = useAppSelector(state => state.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); + + return ( +
+
+

Net Worth

+

Track your wealth over time

+
+ + {/* Summary Cards */} +
+ + + Total Assets + + +

{formatCurrency(totalAssets)}

+
+
+ + + Total Liabilities + + +

{formatCurrency(totalLiabilities)}

+
+
+ + + Net Worth + + +

{formatCurrency(netWorth)}

+
+
+
+ + {/* Chart */} + + + Net Worth Over Time + + +
+ + + + + + + + + + + `$${value / 1000}k`} + /> + [formatCurrency(value), 'Net Worth']} + /> + + + +
+
+
+ + {/* Assets & Liabilities */} +
+ + +
+ Assets + What you own +
+ +
+ + {assets.length === 0 ? ( +

No assets added yet

+ ) : ( +
    + {assets.map(asset => ( +
  • + {asset.name} + {formatCurrency(asset.value)} +
  • + ))} +
+ )} +
+
+ + + +
+ Liabilities + What you owe +
+ +
+ + {liabilities.length === 0 ? ( +

No liabilities added yet

+ ) : ( +
    + {liabilities.map(liability => ( +
  • + {liability.name} + {formatCurrency(liability.balance)} +
  • + ))} +
+ )} +
+
+
+
+ ); +} + diff --git a/frontend-web/src/store/index.ts b/frontend-web/src/store/index.ts index 94311c6..ea1d4fe 100644 --- a/frontend-web/src/store/index.ts +++ b/frontend-web/src/store/index.ts @@ -5,6 +5,51 @@ export type {RootState, AppDispatch} from './store'; // Hooks export {useAppDispatch, useAppSelector} from './hooks'; -// User slice exports -export {setLoading, setUser, clearUser, setError} from './slices/userSlice'; +// User slice +export {setLoading as setUserLoading, setUser, clearUser, setError as setUserError} from './slices/userSlice'; export type {User, UserState} from './slices/userSlice'; + +// Net Worth slice +export { + setLoading as setNetWorthLoading, + setError as setNetWorthError, + addAsset, + updateAsset, + removeAsset, + addLiability, + updateLiability, + removeLiability, + addSnapshot, + setSnapshots, +} from './slices/netWorthSlice'; +export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/netWorthSlice'; + +// Debts slice +export { + setLoading as setDebtsLoading, + setError as setDebtsError, + addDebt, + updateDebt, + removeDebt, + addPayment, + removePayment, + setDebts, + setPayments, +} from './slices/debtsSlice'; +export type {Debt, DebtPayment, DebtsState} from './slices/debtsSlice'; + +// Invoices slice +export { + setLoading as setInvoicesLoading, + setError as setInvoicesError, + addClient, + updateClient, + removeClient, + setClients, + addInvoice, + updateInvoice, + removeInvoice, + setInvoices, + updateInvoiceStatus, +} from './slices/invoicesSlice'; +export type {Client, Invoice, InvoiceLineItem, InvoicesState} from './slices/invoicesSlice'; diff --git a/frontend-web/src/store/slices/debtsSlice.ts b/frontend-web/src/store/slices/debtsSlice.ts new file mode 100644 index 0000000..478b976 --- /dev/null +++ b/frontend-web/src/store/slices/debtsSlice.ts @@ -0,0 +1,88 @@ +import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; + +export interface Debt { + id: string; + name: string; + type: 'credit_card' | 'personal_loan' | 'auto_loan' | 'student_loan' | 'mortgage' | 'other'; + originalBalance: number; + currentBalance: number; + interestRate: number; + minimumPayment: number; + dueDay: number; + lender: string; + createdAt: string; + updatedAt: string; +} + +export interface DebtPayment { + id: string; + debtId: string; + amount: number; + date: string; + note?: string; +} + +export interface DebtsState { + debts: Debt[]; + payments: DebtPayment[]; + isLoading: boolean; + error: string | null; +} + +const initialState: DebtsState = { + debts: [], + payments: [], + isLoading: false, + error: null, +}; + +const debtsSlice = createSlice({ + name: 'debts', + initialState, + reducers: { + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + addDebt: (state, action: PayloadAction) => { + state.debts.push(action.payload); + }, + updateDebt: (state, action: PayloadAction) => { + const index = state.debts.findIndex(d => d.id === action.payload.id); + if (index !== -1) state.debts[index] = action.payload; + }, + removeDebt: (state, action: PayloadAction) => { + state.debts = state.debts.filter(d => d.id !== action.payload); + state.payments = state.payments.filter(p => p.debtId !== action.payload); + }, + addPayment: (state, action: PayloadAction) => { + state.payments.push(action.payload); + }, + removePayment: (state, action: PayloadAction) => { + state.payments = state.payments.filter(p => p.id !== action.payload); + }, + setDebts: (state, action: PayloadAction) => { + state.debts = action.payload; + }, + setPayments: (state, action: PayloadAction) => { + state.payments = action.payload; + }, + }, +}); + +export const { + setLoading, + setError, + addDebt, + updateDebt, + removeDebt, + addPayment, + removePayment, + setDebts, + setPayments, +} = debtsSlice.actions; + +export default debtsSlice.reducer; + diff --git a/frontend-web/src/store/slices/invoicesSlice.ts b/frontend-web/src/store/slices/invoicesSlice.ts new file mode 100644 index 0000000..755192d --- /dev/null +++ b/frontend-web/src/store/slices/invoicesSlice.ts @@ -0,0 +1,118 @@ +import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; + +export interface Client { + id: string; + name: string; + email: string; + phone?: string; + company?: string; + address?: string; + notes?: string; + createdAt: string; +} + +export interface InvoiceLineItem { + id: string; + description: string; + quantity: number; + unitPrice: number; + total: number; +} + +export interface Invoice { + id: string; + invoiceNumber: string; + clientId: string; + status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled'; + issueDate: string; + dueDate: string; + lineItems: InvoiceLineItem[]; + subtotal: number; + tax: number; + total: number; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface InvoicesState { + clients: Client[]; + invoices: Invoice[]; + isLoading: boolean; + error: string | null; +} + +const initialState: InvoicesState = { + clients: [], + invoices: [], + isLoading: false, + error: null, +}; + +const invoicesSlice = createSlice({ + name: 'invoices', + initialState, + reducers: { + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + // Client actions + addClient: (state, action: PayloadAction) => { + state.clients.push(action.payload); + }, + updateClient: (state, action: PayloadAction) => { + const index = state.clients.findIndex(c => c.id === action.payload.id); + if (index !== -1) state.clients[index] = action.payload; + }, + removeClient: (state, action: PayloadAction) => { + state.clients = state.clients.filter(c => c.id !== action.payload); + }, + setClients: (state, action: PayloadAction) => { + state.clients = action.payload; + }, + // Invoice actions + addInvoice: (state, action: PayloadAction) => { + state.invoices.push(action.payload); + }, + updateInvoice: (state, action: PayloadAction) => { + const index = state.invoices.findIndex(i => i.id === action.payload.id); + if (index !== -1) state.invoices[index] = action.payload; + }, + removeInvoice: (state, action: PayloadAction) => { + state.invoices = state.invoices.filter(i => i.id !== action.payload); + }, + setInvoices: (state, action: PayloadAction) => { + state.invoices = action.payload; + }, + updateInvoiceStatus: ( + state, + action: PayloadAction<{id: string; status: Invoice['status']}> + ) => { + const invoice = state.invoices.find(i => i.id === action.payload.id); + if (invoice) { + invoice.status = action.payload.status; + invoice.updatedAt = new Date().toISOString(); + } + }, + }, +}); + +export const { + setLoading, + setError, + addClient, + updateClient, + removeClient, + setClients, + addInvoice, + updateInvoice, + removeInvoice, + setInvoices, + updateInvoiceStatus, +} = invoicesSlice.actions; + +export default invoicesSlice.reducer; + diff --git a/frontend-web/src/store/slices/netWorthSlice.ts b/frontend-web/src/store/slices/netWorthSlice.ts new file mode 100644 index 0000000..4445647 --- /dev/null +++ b/frontend-web/src/store/slices/netWorthSlice.ts @@ -0,0 +1,96 @@ +import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; + +export interface Asset { + id: string; + name: string; + type: 'cash' | 'investment' | 'property' | 'vehicle' | 'other'; + value: number; + updatedAt: string; +} + +export interface Liability { + id: string; + name: string; + type: 'credit_card' | 'loan' | 'mortgage' | 'other'; + balance: number; + updatedAt: string; +} + +export interface NetWorthSnapshot { + id: string; + date: string; + totalAssets: number; + totalLiabilities: number; + netWorth: number; +} + +export interface NetWorthState { + assets: Asset[]; + liabilities: Liability[]; + snapshots: NetWorthSnapshot[]; + isLoading: boolean; + error: string | null; +} + +const initialState: NetWorthState = { + assets: [], + liabilities: [], + snapshots: [], + isLoading: false, + error: null, +}; + +const netWorthSlice = createSlice({ + name: 'netWorth', + initialState, + reducers: { + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + addAsset: (state, action: PayloadAction) => { + state.assets.push(action.payload); + }, + updateAsset: (state, action: PayloadAction) => { + const index = state.assets.findIndex(a => a.id === action.payload.id); + if (index !== -1) state.assets[index] = action.payload; + }, + removeAsset: (state, action: PayloadAction) => { + state.assets = state.assets.filter(a => a.id !== action.payload); + }, + addLiability: (state, action: PayloadAction) => { + state.liabilities.push(action.payload); + }, + updateLiability: (state, action: PayloadAction) => { + const index = state.liabilities.findIndex(l => l.id === action.payload.id); + if (index !== -1) state.liabilities[index] = action.payload; + }, + removeLiability: (state, action: PayloadAction) => { + state.liabilities = state.liabilities.filter(l => l.id !== action.payload); + }, + addSnapshot: (state, action: PayloadAction) => { + state.snapshots.push(action.payload); + }, + setSnapshots: (state, action: PayloadAction) => { + state.snapshots = action.payload; + }, + }, +}); + +export const { + setLoading, + setError, + addAsset, + updateAsset, + removeAsset, + addLiability, + updateLiability, + removeLiability, + addSnapshot, + setSnapshots, +} = netWorthSlice.actions; + +export default netWorthSlice.reducer; + diff --git a/frontend-web/src/store/store.ts b/frontend-web/src/store/store.ts index f73edf6..53b8a63 100644 --- a/frontend-web/src/store/store.ts +++ b/frontend-web/src/store/store.ts @@ -1,10 +1,16 @@ import {configureStore} from '@reduxjs/toolkit'; import userReducer from './slices/userSlice'; +import netWorthReducer from './slices/netWorthSlice'; +import debtsReducer from './slices/debtsSlice'; +import invoicesReducer from './slices/invoicesSlice'; export const store = configureStore({ reducer: { - user: userReducer - } + user: userReducer, + netWorth: netWorthReducer, + debts: debtsReducer, + invoices: invoicesReducer, + }, }); export type RootState = ReturnType;