diff options
author | 2025-04-27 23:02:42 +0530 | |
---|---|---|
committer | 2025-04-27 23:02:42 +0530 | |
commit | 538d933baef56d7ee76f78617b553d63713efa24 (patch) | |
tree | 3fcbc4208849dfa0e5dc8fe5761e103a3591c283 /frontend/src | |
parent | 3941d80ff120238b973451325b834ebd8377281e (diff) | |
download | finance-master.tar.gz finance-master.tar.bz2 finance-master.zip |
Diffstat (limited to 'frontend/src')
-rw-r--r-- | frontend/src/app/(main)/goals/[id]/page.tsx | 290 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/components/goal-form.tsx | 349 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/components/goals-list.tsx | 297 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/edit/[id]/page.tsx | 16 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/layout.tsx | 14 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/new/page.tsx | 16 | ||||
-rw-r--r-- | frontend/src/app/(main)/goals/page.tsx | 44 | ||||
-rw-r--r-- | frontend/src/app/(main)/layout.tsx | 8 | ||||
-rw-r--r-- | frontend/src/app/layout.tsx | 2 | ||||
-rw-r--r-- | frontend/src/components/ui/badge.tsx | 36 | ||||
-rw-r--r-- | frontend/src/components/ui/calendar.tsx | 64 | ||||
-rw-r--r-- | frontend/src/components/ui/popover.tsx | 29 | ||||
-rw-r--r-- | frontend/src/components/ui/progress.tsx | 26 | ||||
-rw-r--r-- | frontend/src/components/ui/select.tsx | 158 | ||||
-rw-r--r-- | frontend/src/components/ui/toast.tsx | 127 | ||||
-rw-r--r-- | frontend/src/components/ui/toaster.tsx | 35 | ||||
-rw-r--r-- | frontend/src/components/ui/use-toast.tsx | 191 | ||||
-rw-r--r-- | frontend/src/lib/api.ts | 304 | ||||
-rw-r--r-- | frontend/src/lib/utils.ts | 14 |
19 files changed, 1847 insertions, 173 deletions
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<GoalWithProgress | null>(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<GoalProgress>(`/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 ( + <div className="container mx-auto py-8 flex justify-center items-center"> + <Loader2 className="h-8 w-8 animate-spin" /> + </div> + ); + } + + if (!goal) { + return ( + <div className="container mx-auto py-8 text-center"> + <p className="mb-4">Goal not found or access denied.</p> + <Link href="/goals"> + <Button>Back to Goals</Button> + </Link> + </div> + ); + } + + const { goal: goalData, percentComplete, amountRemaining, daysRemaining, requiredPerDay, requiredPerMonth, onTrack } = goal; + const isCompleted = goalData.status === "Achieved"; + + return ( + <div className="container mx-auto py-8"> + <div className="mb-6"> + <Link href="/goals"> + <Button variant="ghost" size="sm"> + <ArrowLeft className="mr-2 h-4 w-4" /> + Back to Goals + </Button> + </Link> + </div> + + <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6"> + <div> + <h1 className="text-2xl font-bold tracking-tight">{goalData.name}</h1> + <p className="text-muted-foreground"> + {isCompleted + ? "Goal has been achieved 🎉" + : onTrack + ? "Progress is on track" + : "Progress is behind schedule"} + </p> + </div> + <div className="flex space-x-3 mt-4 md:mt-0"> + <Button + variant="outline" + size="sm" + onClick={recalculateProgress} + disabled={refreshing} + > + {refreshing ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + Recalculate + </Button> + <Link href={`/goals/edit/${goalData.id}`}> + <Button variant="outline" size="sm"> + <Edit className="mr-2 h-4 w-4" /> + Edit + </Button> + </Link> + </div> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + <Card className="lg:col-span-2"> + <CardHeader> + <div className="flex justify-between items-center"> + <CardTitle>Goal Progress</CardTitle> + <Badge variant={isCompleted ? "default" : onTrack ? "outline" : "destructive"}> + {isCompleted ? "Achieved" : onTrack ? "On Track" : "Behind"} + </Badge> + </div> + </CardHeader> + <CardContent> + <div className="mb-6"> + <div className="flex justify-between mb-2"> + <span>Completion</span> + <span>{Math.round(percentComplete)}%</span> + </div> + <Progress value={percentComplete} className="h-3" /> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-4"> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Target Amount</h3> + <p className="text-2xl font-semibold">{formatCurrency(goalData.targetAmount)}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Current Amount</h3> + <p className="text-2xl font-semibold">{formatCurrency(goalData.currentAmount)}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Remaining</h3> + <p className="text-2xl font-semibold">{formatCurrency(amountRemaining)}</p> + </div> + </div> + + <div className="space-y-4"> + {goalData.targetDate && ( + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Target Date</h3> + <p className="text-xl font-semibold">{new Date(goalData.targetDate).toLocaleDateString()}</p> + </div> + )} + {daysRemaining > 0 && ( + <> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Days Remaining</h3> + <p className="text-xl font-semibold">{daysRemaining} days</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Required Per Day</h3> + <p className="text-xl font-semibold">{formatCurrency(requiredPerDay)}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Required Per Month</h3> + <p className="text-xl font-semibold">{formatCurrency(requiredPerMonth)}</p> + </div> + </> + )} + </div> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>Goal Details</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Goal Name</h3> + <p className="font-medium">{goalData.name}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Purpose</h3> + <p>{goalData.name}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Status</h3> + <p>{goalData.status}</p> + </div> + <div> + <h3 className="text-sm font-medium text-muted-foreground mb-1">Created</h3> + <p>{new Date(goalData.createdAt).toLocaleDateString()}</p> + </div> + {isCompleted ? ( + <div className="pt-4"> + <div className="p-4 bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300 rounded-md"> + <p className="font-semibold">🎉 Goal achieved!</p> + <p className="text-sm mt-1"> + Congratulations on achieving your financial goal. + </p> + </div> + </div> + ) : ( + <div className="pt-4"> + <Link href={`/transactions?goalId=${goalData.id}`}> + <Button variant="secondary" className="w-full">View Related Transactions</Button> + </Link> + </div> + )} + </div> + </CardContent> + </Card> + </div> + </div> + ); +}
\ 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<typeof formSchema>; + +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<FormValues>({ + 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 <div className="text-center py-8">Loading goal data...</div>; + } + + return ( + <Card> + <CardContent className="pt-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Goal Name</FormLabel> + <FormControl> + <Input placeholder="e.g., Down Payment for House" {...field} /> + </FormControl> + <FormDescription> + A descriptive name for your financial goal + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <FormField + control={form.control} + name="targetAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>Target Amount</FormLabel> + <FormControl> + <Input + type="number" + placeholder="10000" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + /> + </FormControl> + <FormDescription> + The total amount you want to save + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currentAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>Current Amount</FormLabel> + <FormControl> + <Input + type="number" + placeholder="0" + {...field} + value={field.value || ""} + onChange={(e) => field.onChange(Number(e.target.value) || 0)} + /> + </FormControl> + <FormDescription> + How much you've already saved towards this goal + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <FormField + control={form.control} + name="targetDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Target Date (Optional)</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={`w-full pl-3 text-left font-normal ${ + !field.value ? "text-muted-foreground" : "" + }`} + > + {field.value ? ( + format(field.value, "PPP") + ) : ( + <span>Pick a date</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value || undefined} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormDescription> + When you aim to achieve this goal + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="Active">Active</SelectItem> + <SelectItem value="Paused">Paused</SelectItem> + <SelectItem value="Achieved">Achieved</SelectItem> + <SelectItem value="Cancelled">Cancelled</SelectItem> + </SelectContent> + </Select> + <FormDescription> + The current status of your goal + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="mb-6"> + <h3 className="text-sm font-medium text-muted-foreground mb-2"> + Don't see the amount you need? + </h3> + <p className="text-sm"> + Use the calculator to determine your target amount. + </p> + </div> + + <div className="flex justify-end space-x-4"> + <Button + type="button" + variant="outline" + onClick={() => router.push("/goals")} + disabled={loading} + > + Cancel + </Button> + <Button type="submit" disabled={loading}> + {loading + ? isEditing + ? "Updating..." + : "Creating..." + : isEditing + ? "Update Goal" + : "Create Goal"} + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + ); +}
\ 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<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 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 ( + <div className="container mx-auto py-8"> + <h1 className="text-2xl font-bold tracking-tight mb-6">Edit Goal</h1> + <GoalForm goalId={parseInt(params.id)} /> + </div> + ); +}
\ 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 ( + <div className="container mx-auto py-8"> + <h1 className="text-2xl font-bold tracking-tight mb-6">Create New Goal</h1> + <GoalForm /> + </div> + ); +}
\ 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 ( + <div className="container mx-auto px-4 py-6 md:py-8"> + <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6 gap-4"> + <div> + <h1 className="text-xl sm:text-2xl font-bold tracking-tight">Financial Goals</h1> + <p className="text-sm text-muted-foreground"> + Track your progress towards your financial goals + </p> + </div> + <div className="flex gap-2 sm:gap-3"> + <Button variant="outline" onClick={handleRefresh} disabled={refreshing} size="sm" className="text-xs sm:text-sm"> + <RefreshCw className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> + Refresh + </Button> + <Link href="/goals/new"> + <Button size="sm" className="text-xs sm:text-sm"> + <PlusCircle className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> + New Goal + </Button> + </Link> + </div> + </div> + + <GoalsList /> + </div> + ); +}
\ 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" > - <LayoutDashboardIcon size={18} className={`transition-transform duration-300 ${pathname === '/dashboard' ? 'scale-110' : ''}`} /> + <LayoutDashboardIcon size={18} className={`transition-transform duration-300 ${pathname === '/dashboard' ? 'scale-110' : ''} ml-1`} /> <span className={`ml-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${isSidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}> Dashboard </span> @@ -217,7 +217,7 @@ export default function MainLayout({ `} title="Loans" > - <CoinsIcon size={18} className={`transition-transform duration-300 ${pathname === '/loans' ? 'scale-110' : ''}`} /> + <CoinsIcon size={18} className={`transition-transform duration-300 ${pathname === '/loans' ? 'scale-110' : ''} ml-1`} /> <span className={`ml-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${isSidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}> Loans </span> @@ -234,7 +234,7 @@ export default function MainLayout({ `} title="Goals" > - <TargetIcon size={18} className={`transition-transform duration-300 ${pathname === '/goals' ? 'scale-110' : ''}`} /> + <TargetIcon size={18} className={`transition-transform duration-300 ${pathname === '/goals' ? 'scale-110' : ''} ml-1`} /> <span className={`ml-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${isSidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}> Goals </span> @@ -251,7 +251,7 @@ export default function MainLayout({ `} title="Settings" > - <SettingsIcon size={18} className={`transition-transform duration-300 ${pathname === '/settings' ? 'scale-110' : ''}`} /> + <SettingsIcon size={18} className={`transition-transform duration-300 ${pathname === '/settings' ? 'scale-110' : ''} ml-1`} /> <span className={`ml-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${isSidebarCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}> Settings </span> 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({ <Providers> <NotificationProvider> {children} + <Toaster /> </NotificationProvider> </Providers> </ThemeProvider> 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<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ) +} + +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<typeof DayPicker> + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn("p-3", className)} + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: + "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day: cn( + buttonVariants({ variant: "ghost" }), + "h-9 w-9 p-0 font-normal aria-selected:opacity-100" + ), + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: () => <ChevronLeft className="h-4 w-4" />, + IconRight: () => <ChevronRight className="h-4 w-4" />, + }} + {...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<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </PopoverPrimitive.Portal> +)) +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<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> +>(({ className, value, ...props }, ref) => ( + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-4 w-full overflow-hidden rounded-full bg-secondary", + className + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> +)) +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<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +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<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +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<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +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 ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +}
\ 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<ToasterToast> + } + | { + type: typeof ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: typeof ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() + +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<ToasterToast, "id"> + +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<State>(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<AccountInput>) => 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<AccountInput>) => 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<string, string | number | undefined> = {}; 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<TransactionInput>) => fetchWithAuth(`/transactions/${id}`, { - method: 'PUT', - body: JSON.stringify(transaction) - }), + updateTransaction: (id: number, transaction: Partial<TransactionInput>) => 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<GoalInput>) => api.put(`/goals/${id}`, goal).then(res => res.data), - updateGoal: (id: number, goal: Partial<GoalInput>) => 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<LoanInput>) => 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<LoanInput>) => 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); +} |