From 538d933baef56d7ee76f78617b553d63713efa24 Mon Sep 17 00:00:00 2001 From: Biswa Kalyan Bhuyan Date: Sun, 27 Apr 2025 23:02:42 +0530 Subject: finance: feat: added the goal page with some improvements of ui --- frontend/package-lock.json | 483 ++++++++++++++++++++- frontend/package.json | 7 + frontend/src/app/(main)/goals/[id]/page.tsx | 290 +++++++++++++ .../src/app/(main)/goals/components/goal-form.tsx | 349 +++++++++++++++ .../src/app/(main)/goals/components/goals-list.tsx | 297 +++++++++++++ frontend/src/app/(main)/goals/edit/[id]/page.tsx | 16 + frontend/src/app/(main)/goals/layout.tsx | 14 + frontend/src/app/(main)/goals/new/page.tsx | 16 + frontend/src/app/(main)/goals/page.tsx | 44 ++ frontend/src/app/(main)/layout.tsx | 8 +- frontend/src/app/layout.tsx | 2 + frontend/src/components/ui/badge.tsx | 36 ++ frontend/src/components/ui/calendar.tsx | 64 +++ frontend/src/components/ui/popover.tsx | 29 ++ frontend/src/components/ui/progress.tsx | 26 ++ frontend/src/components/ui/select.tsx | 158 +++++++ frontend/src/components/ui/toast.tsx | 127 ++++++ frontend/src/components/ui/toaster.tsx | 35 ++ frontend/src/components/ui/use-toast.tsx | 191 ++++++++ frontend/src/lib/api.ts | 304 ++++++------- frontend/src/lib/utils.ts | 14 + 21 files changed, 2319 insertions(+), 191 deletions(-) create mode 100644 frontend/src/app/(main)/goals/[id]/page.tsx create mode 100644 frontend/src/app/(main)/goals/components/goal-form.tsx create mode 100644 frontend/src/app/(main)/goals/components/goals-list.tsx create mode 100644 frontend/src/app/(main)/goals/edit/[id]/page.tsx create mode 100644 frontend/src/app/(main)/goals/layout.tsx create mode 100644 frontend/src/app/(main)/goals/new/page.tsx create mode 100644 frontend/src/app/(main)/goals/page.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/use-toast.tsx (limited to 'frontend') diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5eaa75f..32406b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,14 +11,21 @@ "@hookform/resolvers": "^5.0.1", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-popover": "^1.1.11", + "@radix-ui/react-progress": "^1.1.4", + "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-toast": "^1.2.11", "@tanstack/react-query": "^5.74.4", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "framer-motion": "^11.18.2", "lucide-react": "^0.503.0", "next": "15.3.1", "react": "^19.0.0", + "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.56.1", "tailwind-merge": "^3.2.0", @@ -208,6 +215,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, "node_modules/@hookform/resolvers": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", @@ -827,11 +868,63 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", + "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -895,6 +988,20 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz", @@ -998,6 +1105,73 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.11.tgz", + "integrity": "sha512-yFMfZkVA5G3GJnBgb2PxrrcLKm1ZLWXrbYVgdyTl//0TYEIHS9LJbnyz7WWcZ0qCq7hIlJZpRtxeSeIG5T5oJw==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", + "integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", @@ -1066,6 +1240,71 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz", + "integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.2.tgz", + "integrity": "sha512-HjkVHtBkuq+r3zUAZ/CvNWUGKPfuicGDbgtZgiQuFmNcV5F+Tgy24ep2nsAW2nFgvhGPJVqeBZa6KyVN0EyrBA==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@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.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@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.0", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", @@ -1083,6 +1322,39 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.11.tgz", + "integrity": "sha512-Ed2mlOmT+tktOsu2NZBK1bCSHh/uqULu1vWOkpQTVq53EoOuZUZw7FInQoDB3uil5wZc2oe0XN9a7uVZB7/6AQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.0", + "@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-visually-hidden": "1.2.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1163,6 +1435,81 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.0.tgz", + "integrity": "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1511,7 +1858,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", - "devOptional": true, + "dev": true, "dependencies": { "csstype": "^3.0.2" } @@ -1520,7 +1867,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", - "devOptional": true, + "dev": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2216,6 +2563,11 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2240,6 +2592,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2310,7 +2672,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2444,6 +2805,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2468,7 +2840,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "dev": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -2527,6 +2899,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2584,6 +2965,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2614,7 +3003,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2712,7 +3100,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2721,7 +3108,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2757,7 +3143,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2769,7 +3154,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -3339,6 +3723,25 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3354,6 +3757,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/framer-motion": { "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", @@ -3384,7 +3801,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3422,7 +3838,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3454,7 +3869,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3536,7 +3950,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3608,7 +4021,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3620,7 +4032,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -3635,7 +4046,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -4474,7 +4884,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4501,6 +4910,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4948,6 +5376,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4985,6 +5418,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", @@ -5661,7 +6107,8 @@ "node_modules/tailwindcss": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", - "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==" + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "dev": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", diff --git a/frontend/package.json b/frontend/package.json index eb04e83..4623f98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,14 +12,21 @@ "@hookform/resolvers": "^5.0.1", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-popover": "^1.1.11", + "@radix-ui/react-progress": "^1.1.4", + "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-toast": "^1.2.11", "@tanstack/react-query": "^5.74.4", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "framer-motion": "^11.18.2", "lucide-react": "^0.503.0", "next": "15.3.1", "react": "^19.0.0", + "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.56.1", "tailwind-merge": "^3.2.0", diff --git a/frontend/src/app/(main)/goals/[id]/page.tsx b/frontend/src/app/(main)/goals/[id]/page.tsx new file mode 100644 index 0000000..3428ca4 --- /dev/null +++ b/frontend/src/app/(main)/goals/[id]/page.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Edit, ArrowLeft, Loader2, RefreshCw } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { formatCurrency } from "@/lib/utils"; +import { api } from "@/lib/api"; +import { GoalProgress } from "../components/goals-list"; + +export default function GoalDetailPage({ params }: { params: { id: string } }) { + const id = params.id; + const goalId = parseInt(id); + + const [goal, setGoal] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const fetchGoalDetails = useCallback(async () => { + try { + console.log(`Fetching goal details for ID: ${goalId}`); + setLoading(true); + + // Add cache-busting parameter + const response = await api.get(`/goals/${goalId}/progress?cache=${new Date().getTime()}`); + console.log("Goal details received:", response.data); + + // Validate and normalize data + const data = response.data; + if (data && data.goal) { + const sanitizedData = { + ...data, + goal: { + ...data.goal, + targetAmount: Number(data.goal.targetAmount) || 0, + currentAmount: Number(data.goal.currentAmount) || 0, + createdAt: data.goal.createdAt || new Date().toISOString(), + }, + percentComplete: Number(data.percentComplete) || 0, + amountRemaining: Number(data.amountRemaining) || 0, + daysRemaining: Number(data.daysRemaining) || 0, + requiredPerDay: Number(data.requiredPerDay) || 0, + requiredPerMonth: Number(data.requiredPerMonth) || 0, + }; + console.log("Processed goal data:", sanitizedData); + setGoal(sanitizedData); + } else { + console.error("Invalid goal data format:", data); + throw new Error("Invalid goal data received"); + } + } catch (error) { + console.error("Error fetching goal details:", error); + toast({ + title: "Error", + description: "Failed to fetch goal details. Please try again.", + variant: "destructive", + }); + router.push("/goals"); + } finally { + setLoading(false); + } + }, [goalId, toast, router]); + + // Fetch goal details when component mounts + useEffect(() => { + if (!id) { + toast({ + title: "Error", + description: "Goal ID is missing. Please try again.", + variant: "destructive", + }); + router.push("/goals"); + return; + } + + fetchGoalDetails(); + }, [id, fetchGoalDetails, router, toast]); + + const recalculateProgress = async () => { + if (isNaN(goalId)) { + toast({ + title: "Error", + description: "Invalid goal ID", + variant: "destructive", + }); + return; + } + + try { + setRefreshing(true); + await api.post(`/goals/${goalId}/recalculate`); + toast({ + title: "Progress recalculated", + description: "Your goal progress has been recalculated based on transactions.", + }); + fetchGoalDetails(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to recalculate goal progress. Please try again.", + variant: "destructive", + }); + console.error("Error recalculating goal progress:", error); + } finally { + setRefreshing(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!goal) { + return ( +
+

Goal not found or access denied.

+ + + +
+ ); + } + + const { goal: goalData, percentComplete, amountRemaining, daysRemaining, requiredPerDay, requiredPerMonth, onTrack } = goal; + const isCompleted = goalData.status === "Achieved"; + + return ( +
+
+ + + +
+ +
+
+

{goalData.name}

+

+ {isCompleted + ? "Goal has been achieved 🎉" + : onTrack + ? "Progress is on track" + : "Progress is behind schedule"} +

+
+
+ + + + +
+
+ +
+ + +
+ Goal Progress + + {isCompleted ? "Achieved" : onTrack ? "On Track" : "Behind"} + +
+
+ +
+
+ Completion + {Math.round(percentComplete)}% +
+ +
+ +
+
+
+

Target Amount

+

{formatCurrency(goalData.targetAmount)}

+
+
+

Current Amount

+

{formatCurrency(goalData.currentAmount)}

+
+
+

Remaining

+

{formatCurrency(amountRemaining)}

+
+
+ +
+ {goalData.targetDate && ( +
+

Target Date

+

{new Date(goalData.targetDate).toLocaleDateString()}

+
+ )} + {daysRemaining > 0 && ( + <> +
+

Days Remaining

+

{daysRemaining} days

+
+
+

Required Per Day

+

{formatCurrency(requiredPerDay)}

+
+
+

Required Per Month

+

{formatCurrency(requiredPerMonth)}

+
+ + )} +
+
+
+
+ + + + Goal Details + + +
+
+

Goal Name

+

{goalData.name}

+
+
+

Purpose

+

{goalData.name}

+
+
+

Status

+

{goalData.status}

+
+
+

Created

+

{new Date(goalData.createdAt).toLocaleDateString()}

+
+ {isCompleted ? ( +
+
+

🎉 Goal achieved!

+

+ Congratulations on achieving your financial goal. +

+
+
+ ) : ( +
+ + + +
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/components/goal-form.tsx b/frontend/src/app/(main)/goals/components/goal-form.tsx new file mode 100644 index 0000000..6b1cbac --- /dev/null +++ b/frontend/src/app/(main)/goals/components/goal-form.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { useToast } from "@/components/ui/use-toast"; +import { api } from "@/lib/api"; + +// Validation schema +const formSchema = z.object({ + name: z + .string() + .min(3, { message: "Name must be at least 3 characters" }) + .max(100, { message: "Name must be less than 100 characters" }), + targetAmount: z + .number() + .min(1, { message: "Target amount must be greater than 0" }), + currentAmount: z + .number() + .min(0, { message: "Current amount cannot be negative" }) + .optional(), + targetDate: z.date().optional(), + status: z.enum(["Active", "Paused", "Achieved", "Cancelled"]), +}); + +type FormValues = z.infer; + +interface GoalFormProps { + goalId?: number; + isEditing?: boolean; + onSuccess?: () => void; +} + +export function GoalForm({ + goalId, + isEditing = false, + onSuccess +}: GoalFormProps) { + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + // Set up form with validation + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + targetAmount: 0, + currentAmount: 0, + status: "Active", + }, + }); + + const fetchGoalData = useCallback(async () => { + setInitialLoading(true); + try { + const response = await api.get(`/goals/${goalId}`); + const goalData = response.data; + + // Set form values + form.reset({ + name: goalData.name, + targetAmount: goalData.targetAmount, + currentAmount: goalData.currentAmount, + status: goalData.status as "Active" | "Paused" | "Achieved" | "Cancelled", + ...(goalData.targetDate && { targetDate: new Date(goalData.targetDate) }), + }); + } catch (error) { + toast({ + title: "Error", + description: "Failed to fetch goal data. Please try again.", + variant: "destructive", + }); + console.error("Error fetching goal:", error); + router.push("/goals"); + } finally { + setInitialLoading(false); + } + }, [goalId, form, toast, router]); + + // Fetch goal data if editing + useEffect(() => { + if (isEditing && goalId) { + fetchGoalData(); + } + }, [isEditing, goalId, fetchGoalData]); + + const onSubmit = async (values: FormValues) => { + try { + setLoading(true); + + // Format data for API + const formattedData = { + ...values, + targetDate: values.targetDate ? format(values.targetDate, "yyyy-MM-dd") : undefined, + }; + + console.log("Submitting goal:", formattedData); + + if (isEditing) { + // Update existing goal + await api.put(`/goals/${goalId}`, formattedData); + toast({ + title: "Goal updated", + description: "Your goal has been updated successfully.", + }); + } else { + // Create new goal + const response = await api.post("/goals", formattedData); + console.log("Goal created response:", response.data); + toast({ + title: "Goal created", + description: "Your new goal has been created successfully.", + }); + } + + // Call onSuccess callback if provided + if (onSuccess) { + onSuccess(); + } else { + // Force a full page reload directly to the goals page + window.location.href = "/goals"; + } + + } catch (error) { + toast({ + title: "Error", + description: `Failed to ${isEditing ? "update" : "create"} goal. Please try again.`, + variant: "destructive", + }); + console.error(`Error ${isEditing ? "updating" : "creating"} goal:`, error); + } finally { + setLoading(false); + } + }; + + if (initialLoading) { + return
Loading goal data...
; + } + + return ( + + +
+ + ( + + Goal Name + + + + + A descriptive name for your financial goal + + + + )} + /> + +
+ ( + + Target Amount + + field.onChange(Number(e.target.value))} + /> + + + The total amount you want to save + + + + )} + /> + + ( + + Current Amount + + field.onChange(Number(e.target.value) || 0)} + /> + + + How much you've already saved towards this goal + + + + )} + /> +
+ +
+ ( + + Target Date (Optional) + + + + + + + + date < new Date()} + initialFocus + /> + + + + When you aim to achieve this goal + + + + )} + /> + + ( + + Status + + + The current status of your goal + + + + )} + /> +
+ +
+

+ Don't see the amount you need? +

+

+ Use the calculator to determine your target amount. +

+
+ +
+ + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/components/goals-list.tsx b/frontend/src/app/(main)/goals/components/goals-list.tsx new file mode 100644 index 0000000..65f998a --- /dev/null +++ b/frontend/src/app/(main)/goals/components/goals-list.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Edit, Trash2, BarChart, AlertCircle } from "lucide-react"; +import Link from "next/link"; +import { useToast } from "@/components/ui/use-toast"; +import { formatCurrency } from "@/lib/utils"; +import { api } from "@/lib/api"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// Type definitions +export interface Goal { + id: number; + name: string; + targetAmount: number; + currentAmount: number; + status: string; + targetDate: string; +} + +export interface GoalProgress { + goal: Goal; + percentComplete: number; + amountRemaining: number; + daysRemaining: number; + requiredPerDay: number; + requiredPerMonth: number; + onTrack: boolean; +} + +// Backend API response type +interface ApiGoal { + ID: number; + Name: string; + TargetAmount: number; + CurrentAmount: number; + Status: string; + TargetDate: string; + // Other fields might exist but we don't need them +} + +interface ApiGoalProgress { + goal: ApiGoal; + percentComplete: number; + amountRemaining: number; + daysRemaining: number; + requiredPerDay: number; + requiredPerMonth: number; + onTrack: boolean; +} + +export function GoalsList() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [goalToDelete, setGoalToDelete] = useState<{id: number, name: string} | null>(null); + const { toast } = useToast(); + + const fetchGoals = useCallback(async () => { + try { + setLoading(true); + + // Add timestamp parameter to prevent caching + const response = await api.get(`/goals/progress/all?cache=${new Date().getTime()}`); + + if (!response.data || !Array.isArray(response.data)) { + setGoals([]); + return; + } + + // Validate and sanitize the data before setting state + const validatedGoals = response.data.map((goalProgress: ApiGoalProgress) => { + // Map API field names (uppercase) to our component field names (lowercase) + const mappedGoal = { + id: goalProgress.goal.ID, + name: goalProgress.goal.Name, + targetAmount: Number(goalProgress.goal.TargetAmount) || 0, + currentAmount: Number(goalProgress.goal.CurrentAmount) || 0, + status: goalProgress.goal.Status, + targetDate: goalProgress.goal.TargetDate + }; + + return { + goal: mappedGoal, + percentComplete: Number(goalProgress.percentComplete) || 0, + amountRemaining: Number(goalProgress.amountRemaining) || 0, + daysRemaining: Number(goalProgress.daysRemaining) || 0, + requiredPerDay: Number(goalProgress.requiredPerDay) || 0, + requiredPerMonth: Number(goalProgress.requiredPerMonth) || 0, + onTrack: Boolean(goalProgress.onTrack) + }; + }); + + setGoals(validatedGoals); + } catch (error) { + console.error("Error fetching goals:", error); + toast({ + title: "Error", + description: "Failed to fetch goals. Please try again later.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }, [toast]); + + // Fetch goals when component mounts or if URL contains a refresh parameter + useEffect(() => { + fetchGoals(); + + // Add event listener to refresh when the window gains focus (user comes back to the tab) + window.addEventListener("focus", fetchGoals); + + return () => { + window.removeEventListener("focus", fetchGoals); + }; + }, [fetchGoals]); + + const confirmDelete = (id: number, name: string) => { + setGoalToDelete({ id, name }); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!goalToDelete) return; + + try { + const goalId = Number(goalToDelete.id); + await api.delete(`/goals/${goalId}`); + toast({ + title: "Goal deleted", + description: "The goal has been successfully deleted.", + }); + fetchGoals(); + } catch (error) { + console.error("Error deleting goal:", error); + toast({ + title: "Error", + description: "Failed to delete the goal. Please try again.", + variant: "destructive", + }); + } finally { + setDeleteDialogOpen(false); + setGoalToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setGoalToDelete(null); + }; + + if (loading) { + return
Loading goals...
; + } + + if (!goals || goals.length === 0) { + return ( +
+

You haven't created any goals yet.

+ + + +
+ ); + } + + return ( + <> +
+ {goals.map((goalProgress, index) => { + const { goal, percentComplete, amountRemaining, daysRemaining, onTrack } = goalProgress; + const isCompleted = goal.status === "Achieved"; + + return ( + + +
+ {goal.name} + + {isCompleted ? "Achieved" : onTrack ? "On Track" : "Behind"} + +
+

+ Saving for: {goal.name} +

+
+ +
+
+ Progress + {Math.round(percentComplete)}% +
+ +
+ +
+
+ Target + {formatCurrency(goal.targetAmount)} +
+
+ Current + {formatCurrency(goal.currentAmount)} +
+
+ Remaining + {formatCurrency(amountRemaining)} +
+ {daysRemaining > 0 && ( +
+ Days Left + {daysRemaining} +
+ )} + {goal.targetDate && ( +
+ Target Date + {new Date(goal.targetDate).toLocaleDateString()} +
+ )} +
+
+ +
+ + + + + + + +
+
+
+ ); + })} +
+ + + + + + + Confirm Deletion + + + Are you sure you want to delete the goal “{goalToDelete?.name}”? This action cannot be undone. + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/edit/[id]/page.tsx b/frontend/src/app/(main)/goals/edit/[id]/page.tsx new file mode 100644 index 0000000..ed51f92 --- /dev/null +++ b/frontend/src/app/(main)/goals/edit/[id]/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import { GoalForm } from "../../components/goal-form"; + +export const metadata: Metadata = { + title: "Edit Goal | Finance", + description: "Edit your financial goal", +}; + +export default function EditGoalPage({ params }: { params: { id: string } }) { + return ( +
+

Edit Goal

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/layout.tsx b/frontend/src/app/(main)/goals/layout.tsx new file mode 100644 index 0000000..25ea209 --- /dev/null +++ b/frontend/src/app/(main)/goals/layout.tsx @@ -0,0 +1,14 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Goals | Finance", + description: "Manage your financial goals", +}; + +export default function GoalsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/new/page.tsx b/frontend/src/app/(main)/goals/new/page.tsx new file mode 100644 index 0000000..7640659 --- /dev/null +++ b/frontend/src/app/(main)/goals/new/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import { GoalForm } from "../components/goal-form"; + +export const metadata: Metadata = { + title: "New Goal | Finance", + description: "Create a new financial goal", +}; + +export default function NewGoalPage() { + return ( +
+

Create New Goal

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/goals/page.tsx b/frontend/src/app/(main)/goals/page.tsx new file mode 100644 index 0000000..b703cff --- /dev/null +++ b/frontend/src/app/(main)/goals/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { PlusCircle, RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { GoalsList } from "./components/goals-list"; +import { useState } from "react"; + +export default function GoalsPage() { + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = () => { + setRefreshing(true); + // Force reload the page + window.location.href = `/goals?refresh=${new Date().getTime()}`; + }; + + return ( +
+
+
+

Financial Goals

+

+ Track your progress towards your financial goals +

+
+
+ + + + +
+
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(main)/layout.tsx b/frontend/src/app/(main)/layout.tsx index 11e557b..28197e3 100644 --- a/frontend/src/app/(main)/layout.tsx +++ b/frontend/src/app/(main)/layout.tsx @@ -200,7 +200,7 @@ export default function MainLayout({ `} title="Dashboard" > - + Dashboard @@ -217,7 +217,7 @@ export default function MainLayout({ `} title="Loans" > - + Loans @@ -234,7 +234,7 @@ export default function MainLayout({ `} title="Goals" > - + Goals @@ -251,7 +251,7 @@ export default function MainLayout({ `} title="Settings" > - + Settings diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index d1442c8..5d5da25 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Providers } from "./providers"; import { ThemeProvider } from "@/components/shared/ThemeProvider"; import { NotificationProvider } from "@/components/shared/NotificationContext"; +import { Toaster } from "@/components/ui/toaster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,6 +33,7 @@ export default function RootLayout({ {children} + diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd86b2b --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 0000000..144b6b6 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } \ No newline at end of file diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..8577b8a --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..bd761c6 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } \ No newline at end of file diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..c6bde11 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} \ No newline at end of file diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..800ff84 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} \ No newline at end of file diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 0000000..62bb68a --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/use-toast.tsx b/frontend/src/components/ui/use-toast.tsx new file mode 100644 index 0000000..effb83e --- /dev/null +++ b/frontend/src/components/ui/use-toast.tsx @@ -0,0 +1,191 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 5 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +// Define action types as enum or const object +export const ActionType = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_VALUE + return count.toString() +} + +type Action = + | { + type: typeof ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: typeof ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: typeof ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: typeof ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 11cee62..67ea975 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,72 +1,52 @@ +import axios from 'axios'; + // API base URL const API_BASE_URL = 'http://localhost:8080/api/v1'; -// Helper function for fetching data with authorization -async function fetchWithAuth(url: string, options: RequestInit = {}) { - // Get token from local storage - const token = localStorage.getItem('token'); - - // Set up headers with authorization - const headers = { +// Create axios instance with defaults +export const api = axios.create({ + baseURL: API_BASE_URL, + headers: { 'Content-Type': 'application/json', - ...(token ? { 'Authorization': `Bearer ${token}` } : {}), - ...options.headers - }; - - // Perform fetch - const response = await fetch(`${API_BASE_URL}${url}`, { - ...options, - headers - }); - - // Handle unauthorized responses - if (response.status === 401) { - localStorage.removeItem('token'); - window.location.href = '/login'; - throw new Error('Unauthorized'); - } - - // Parse response - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.error || `API Error: ${response.status}`); + }, +}); + +// Add auth interceptor +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Handle auth errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401 && typeof window !== 'undefined') { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(error); } - - return response.json(); -} +); // Auth API export const authApi = { login: async (email: string, password: string) => { - const response = await fetch(`${API_BASE_URL}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.error || 'Login failed'); - } - - const data = await response.json(); - localStorage.setItem('token', data.token); - return data; + const response = await api.post('/auth/login', { email, password }); + const { token } = response.data; + localStorage.setItem('token', token); + return response.data; }, signup: async (name: string, email: string, password: string) => { - const response = await fetch(`${API_BASE_URL}/auth/signup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.error || 'Signup failed'); - } - - return response.json(); + const response = await api.post('/auth/signup', { name, email, password }); + return response.data; }, logout: () => { @@ -77,14 +57,14 @@ export const authApi = { // Account API export interface Account { - ID: number; - CreatedAt: string; - UpdatedAt: string; - DeletedAt: string | null; - UserID: number; - Name: string; - Type: string; - Balance: number; + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userID: number; + name: string; + type: string; + balance: number; } export interface AccountInput { @@ -94,34 +74,26 @@ export interface AccountInput { } export const accountApi = { - getAccounts: () => fetchWithAuth('/accounts'), - getAccount: (id: number) => fetchWithAuth(`/accounts/${id}`), - createAccount: (account: AccountInput) => fetchWithAuth('/accounts', { - method: 'POST', - body: JSON.stringify(account) - }), - updateAccount: (id: number, account: Partial) => fetchWithAuth(`/accounts/${id}`, { - method: 'PUT', - body: JSON.stringify(account) - }), - deleteAccount: (id: number) => fetchWithAuth(`/accounts/${id}`, { - method: 'DELETE' - }) + getAccounts: () => api.get('/accounts').then(res => res.data), + getAccount: (id: number) => api.get(`/accounts/${id}`).then(res => res.data), + createAccount: (account: AccountInput) => api.post('/accounts', account).then(res => res.data), + updateAccount: (id: number, account: Partial) => api.put(`/accounts/${id}`, account).then(res => res.data), + deleteAccount: (id: number) => api.delete(`/accounts/${id}`).then(res => res.data) }; // Transaction API export interface Transaction { - ID: number; - CreatedAt: string; - UpdatedAt: string; - DeletedAt: string | null; - UserID: number; - AccountID: number | null; - Description: string; - Amount: number; - Type: "Income" | "Expense"; - Date: string; - Category: string; + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userID: number; + accountID: number | null; + description: string; + amount: number; + type: "Income" | "Expense"; + date: string; + category: string; } export interface TransactionInput { @@ -139,55 +111,54 @@ export interface TransactionFilters { category?: string; startDate?: string; // YYYY-MM-DD format endDate?: string; // YYYY-MM-DD format + goalId?: number; } export const transactionApi = { getTransactions: (filters?: TransactionFilters) => { - let queryParams = ''; - + const params: Record = {}; if (filters) { - const params = new URLSearchParams(); - if (filters.type) params.append('type', filters.type); - if (filters.accountId) params.append('account_id', filters.accountId.toString()); - if (filters.category) params.append('category', filters.category); - if (filters.startDate) params.append('start_date', filters.startDate); - if (filters.endDate) params.append('end_date', filters.endDate); - - queryParams = `?${params.toString()}`; + if (filters.type) params.type = filters.type; + if (filters.accountId) params.account_id = filters.accountId; + if (filters.category) params.category = filters.category; + if (filters.startDate) params.start_date = filters.startDate; + if (filters.endDate) params.end_date = filters.endDate; + if (filters.goalId) params.goal_id = filters.goalId; } - - return fetchWithAuth(`/transactions${queryParams}`); + return api.get('/transactions', { params }).then(res => res.data); }, - getTransaction: (id: number) => fetchWithAuth(`/transactions/${id}`), + getTransaction: (id: number) => api.get(`/transactions/${id}`).then(res => res.data), - createTransaction: (transaction: TransactionInput) => fetchWithAuth('/transactions', { - method: 'POST', - body: JSON.stringify(transaction) - }), + createTransaction: (transaction: TransactionInput) => api.post('/transactions', transaction).then(res => res.data), - updateTransaction: (id: number, transaction: Partial) => fetchWithAuth(`/transactions/${id}`, { - method: 'PUT', - body: JSON.stringify(transaction) - }), + updateTransaction: (id: number, transaction: Partial) => api.put(`/transactions/${id}`, transaction).then(res => res.data), - deleteTransaction: (id: number) => fetchWithAuth(`/transactions/${id}`, { - method: 'DELETE' - }) + deleteTransaction: (id: number) => api.delete(`/transactions/${id}`).then(res => res.data) }; // Goal API export interface Goal { - ID: number; - CreatedAt: string; - UpdatedAt: string; - DeletedAt: string | null; - UserID: number; - Name: string; - TargetAmount: number; - CurrentAmount: number; - TargetDate: string | null; - Status: "Active" | "Achieved" | "Cancelled"; + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userID: number; + name: string; + targetAmount: number; + currentAmount: number; + targetDate: string | null; + status: "Active" | "Paused" | "Achieved" | "Cancelled"; +} + +export interface GoalProgress { + goal: Goal; + percentComplete: number; + amountRemaining: number; + daysRemaining: number; + requiredPerDay: number; + requiredPerMonth: number; + onTrack: boolean; } export interface GoalInput { @@ -195,51 +166,54 @@ export interface GoalInput { targetAmount: number; currentAmount?: number; targetDate?: string; // YYYY-MM-DD format - status?: "Active" | "Achieved" | "Cancelled"; + status?: "Active" | "Paused" | "Achieved" | "Cancelled"; } export const goalApi = { - getGoals: (status?: "Active" | "Achieved" | "Cancelled") => { - const queryParams = status ? `?status=${status}` : ''; - return fetchWithAuth(`/goals${queryParams}`); + getGoals: (status?: "Active" | "Paused" | "Achieved" | "Cancelled") => { + const params = status ? { status } : {}; + return api.get('/goals', { params }).then(res => res.data); }, - getGoal: (id: number) => fetchWithAuth(`/goals/${id}`), + getGoal: (id: number) => api.get(`/goals/${id}`).then(res => res.data), + + createGoal: (goal: GoalInput) => api.post('/goals', goal).then(res => res.data), - createGoal: (goal: GoalInput) => fetchWithAuth('/goals', { - method: 'POST', - body: JSON.stringify(goal) - }), + updateGoal: (id: number, goal: Partial) => api.put(`/goals/${id}`, goal).then(res => res.data), - updateGoal: (id: number, goal: Partial) => fetchWithAuth(`/goals/${id}`, { - method: 'PUT', - body: JSON.stringify(goal) - }), + updateGoalProgress: (id: number, currentAmount: number) => + api.patch(`/goals/${id}/progress`, { currentAmount }).then(res => res.data), - updateGoalProgress: (id: number, currentAmount: number) => fetchWithAuth(`/goals/${id}/progress`, { - method: 'PATCH', - body: JSON.stringify({ currentAmount }) - }), + deleteGoal: (id: number) => api.delete(`/goals/${id}`).then(res => res.data), - deleteGoal: (id: number) => fetchWithAuth(`/goals/${id}`, { - method: 'DELETE' - }) + // New goal progress tracking endpoints + getGoalProgress: (id: number) => api.get(`/goals/${id}/progress`).then(res => res.data), + + getAllGoalsProgress: (status?: string) => { + const params = status ? { status } : {}; + return api.get('/goals/progress/all', { params }).then(res => res.data); + }, + + linkTransactionToGoal: (goalId: number, transactionId: number) => + api.post(`/goals/${goalId}/link-transaction`, { transactionId }).then(res => res.data), + + recalculateGoalProgress: (id: number) => api.post(`/goals/${id}/recalculate`).then(res => res.data) }; // Loan API export interface Loan { - ID: number; - CreatedAt: string; - UpdatedAt: string; - DeletedAt: string | null; - UserID: number; - AccountID: number | null; - Name: string; - OriginalAmount: number; - CurrentBalance: number; - InterestRate: number; - StartDate: string; - EndDate: string; + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userID: number; + accountID: number | null; + name: string; + originalAmount: number; + currentBalance: number; + interestRate: number; + startDate: string; + endDate: string; } export interface LoanInput { @@ -253,22 +227,14 @@ export interface LoanInput { } export const loanApi = { - getLoans: () => fetchWithAuth('/loans'), - getLoan: (id: number) => fetchWithAuth(`/loans/${id}`), - createLoan: (loan: LoanInput) => fetchWithAuth('/loans', { - method: 'POST', - body: JSON.stringify(loan) - }), - updateLoan: (id: number, loan: Partial) => fetchWithAuth(`/loans/${id}`, { - method: 'PUT', - body: JSON.stringify(loan) - }), - deleteLoan: (id: number) => fetchWithAuth(`/loans/${id}`, { - method: 'DELETE' - }) + getLoans: () => api.get('/loans').then(res => res.data), + getLoan: (id: number) => api.get(`/loans/${id}`).then(res => res.data), + createLoan: (loan: LoanInput) => api.post('/loans', loan).then(res => res.data), + updateLoan: (id: number, loan: Partial) => api.put(`/loans/${id}`, loan).then(res => res.data), + deleteLoan: (id: number) => api.delete(`/loans/${id}`).then(res => res.data) }; // User API export const userApi = { - getProfile: () => fetchWithAuth('/users/me') + getProfile: () => api.get('/users/me').then(res => res.data) }; \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..25e4a61 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,17 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function formatCurrency(amount: number | null | undefined): string { + // Check if amount is null, undefined or NaN + if (amount === null || amount === undefined || isNaN(amount)) { + return '$0'; + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} -- cgit v1.2.3-59-g8ed1b