diff options
Diffstat (limited to 'frontend/src/app/(main)/goals/components/goals-list.tsx')
-rw-r--r-- | frontend/src/app/(main)/goals/components/goals-list.tsx | 297 |
1 files changed, 297 insertions, 0 deletions
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<GoalProgress[]>([]); + 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 <div className="text-center py-8">Loading goals...</div>; + } + + if (!goals || goals.length === 0) { + return ( + <div className="text-center py-8"> + <p className="text-muted-foreground mb-4">You haven't created any goals yet.</p> + <Link href="/goals/new"> + <Button>Create your first goal</Button> + </Link> + </div> + ); + } + + return ( + <> + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6"> + {goals.map((goalProgress, index) => { + const { goal, percentComplete, amountRemaining, daysRemaining, onTrack } = goalProgress; + const isCompleted = goal.status === "Achieved"; + + return ( + <Card key={`goal-${goal.id}-${index}`} className="flex flex-col h-full"> + <CardHeader className="pb-2"> + <div className="flex flex-wrap justify-between items-start gap-2"> + <CardTitle className="text-base sm:text-lg break-words mr-2">{goal.name}</CardTitle> + <Badge variant={isCompleted ? "default" : onTrack ? "outline" : "destructive"} className="whitespace-nowrap"> + {isCompleted ? "Achieved" : onTrack ? "On Track" : "Behind"} + </Badge> + </div> + <p className="text-xs sm:text-sm text-muted-foreground mt-1 break-words"> + Saving for: {goal.name} + </p> + </CardHeader> + <CardContent className="flex-1 py-2"> + <div className="mb-3"> + <div className="flex justify-between mb-1 text-sm"> + <span>Progress</span> + <span>{Math.round(percentComplete)}%</span> + </div> + <Progress value={percentComplete} className="h-2" /> + </div> + + <div className="space-y-1 text-xs sm:text-sm"> + <div key={`target-${goal.id}`} className="flex justify-between"> + <span className="text-muted-foreground">Target</span> + <span className="font-medium">{formatCurrency(goal.targetAmount)}</span> + </div> + <div key={`current-${goal.id}`} className="flex justify-between"> + <span className="text-muted-foreground">Current</span> + <span className="font-medium">{formatCurrency(goal.currentAmount)}</span> + </div> + <div key={`remaining-${goal.id}`} className="flex justify-between"> + <span className="text-muted-foreground">Remaining</span> + <span className="font-medium">{formatCurrency(amountRemaining)}</span> + </div> + {daysRemaining > 0 && ( + <div key={`days-left-${goal.id}`} className="flex justify-between"> + <span className="text-muted-foreground">Days Left</span> + <span className="font-medium">{daysRemaining}</span> + </div> + )} + {goal.targetDate && ( + <div key={`target-date-${goal.id}`} className="flex justify-between"> + <span className="text-muted-foreground">Target Date</span> + <span className="font-medium">{new Date(goal.targetDate).toLocaleDateString()}</span> + </div> + )} + </div> + </CardContent> + <CardFooter className="pt-2 flex flex-wrap gap-2"> + <div className="flex flex-col sm:flex-row gap-2 w-full"> + <Link key={`details-link-${goal.id}`} href={`/goals/${goal.id}`} className="flex-1 min-w-[80px]"> + <Button variant="outline" size="sm" className="w-full text-xs"> + <BarChart className="mr-1 h-3 w-3" /> + Details + </Button> + </Link> + <Link key={`edit-link-${goal.id}`} href={`/goals/edit/${goal.id}`} className="flex-1 min-w-[80px]"> + <Button variant="outline" size="sm" className="w-full text-xs"> + <Edit className="mr-1 h-3 w-3" /> + Edit + </Button> + </Link> + <Button + key={`delete-button-${goal.id}`} + variant="outline" + size="sm" + className="flex-1 min-w-[80px] text-xs" + onClick={() => confirmDelete(goal.id, goal.name)} + > + <Trash2 className="mr-1 h-3 w-3" /> + Delete + </Button> + </div> + </CardFooter> + </Card> + ); + })} + </div> + + <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <DialogContent className="sm:max-w-[425px] p-4 sm:p-6 gap-4"> + <DialogHeader className="space-y-3"> + <DialogTitle className="flex items-center gap-2 text-lg"> + <AlertCircle className="h-5 w-5 text-destructive" /> + Confirm Deletion + </DialogTitle> + <DialogDescription className="text-sm"> + Are you sure you want to delete the goal “{goalToDelete?.name}”? This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter className="mt-4 flex-col sm:flex-row gap-2"> + <Button + variant="outline" + onClick={handleDeleteCancel} + className="w-full sm:w-auto" + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDeleteConfirm} + className="w-full sm:w-auto" + > + Delete Goal + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file |