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/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 + 9 files changed, 1032 insertions(+), 4 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 (limited to 'frontend/src/app') 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} + -- cgit v1.2.3-59-g8ed1b