diff options
Diffstat (limited to 'frontend/src')
24 files changed, 1812 insertions, 0 deletions
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..9651b4b --- /dev/null +++ b/frontend/src/app/(auth)/layout.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function AuthLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const router = useRouter(); + + useEffect(() => { + // If already logged in, redirect to dashboard + const token = localStorage.getItem('token'); + if (token) { + router.push('/dashboard'); + } + }, [router]); + + return ( + <div className="flex min-h-screen flex-col items-center justify-center bg-muted/40"> + <div className="w-full max-w-md rounded-lg border bg-card p-6 shadow-sm"> + <div className="flex flex-col space-y-2 text-center mb-6"> + <h1 className="text-2xl font-semibold tracking-tight"> + Finance Management + </h1> + <p className="text-sm text-muted-foreground"> + Manage your personal finances + </p> + </div> + {children} + </div> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..7460b8a --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authApi } from '@/lib/api'; +import { useNotification } from '@/components/shared/NotificationContext'; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { showNotification } = useNotification(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + // Check if the user was redirected from signup + if (searchParams.get('signup') === 'success') { + showNotification('success', 'Account created successfully! Please log in.'); + } + }, [searchParams, showNotification]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + await authApi.login(email, password); + showNotification('success', 'Logged in successfully!'); + router.push('/dashboard'); + } catch (err: any) { + setError(err.message || 'Login failed'); + } finally { + setIsLoading(false); + } + }; + + return ( + <div> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="your@email.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + /> + </div> + + {error && ( + <div className="text-sm text-red-500"> + {error} + </div> + )} + + <Button type="submit" className="w-full" disabled={isLoading}> + {isLoading ? 'Logging in...' : 'Login'} + </Button> + </form> + + <div className="mt-4 text-center text-sm"> + Don't have an account?{' '} + <Link href="/signup" className="text-primary underline underline-offset-2"> + Sign up + </Link> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/(auth)/signup/page.tsx b/frontend/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..af25031 --- /dev/null +++ b/frontend/src/app/(auth)/signup/page.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authApi } from '@/lib/api'; +import { useNotification } from '@/components/shared/NotificationContext'; + +export default function SignupPage() { + const router = useRouter(); + const { showNotification } = useNotification(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setIsLoading(true); + + try { + await authApi.signup(name, email, password); + // Show success notification + showNotification('success', `Your email ${email} has been successfully registered!`); + // Redirect to login after successful signup with a slight delay to see the notification + setTimeout(() => { + router.push('/login?signup=success'); + }, 1500); + } catch (err: any) { + setError(err.message || 'Signup failed'); + } finally { + setIsLoading(false); + } + }; + + return ( + <div> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="name">Name</Label> + <Input + id="name" + placeholder="Your Name" + value={name} + onChange={(e) => setName(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="your@email.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="confirmPassword">Confirm Password</Label> + <Input + id="confirmPassword" + type="password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + required + /> + </div> + + {error && ( + <div className="text-sm text-red-500"> + {error} + </div> + )} + + <Button type="submit" className="w-full" disabled={isLoading}> + {isLoading ? 'Creating Account...' : 'Create Account'} + </Button> + </form> + + <div className="mt-4 text-center text-sm"> + Already have an account?{' '} + <Link href="/login" className="text-primary underline underline-offset-2"> + Login + </Link> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/(main)/dashboard/page.tsx b/frontend/src/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..ac5fd0d --- /dev/null +++ b/frontend/src/app/(main)/dashboard/page.tsx @@ -0,0 +1,10 @@ +export default function DashboardPage() { + return ( + <div> + <h1 className="text-3xl font-bold mb-6">Dashboard</h1> + <p className="text-muted-foreground"> + Welcome to your Finance Management Dashboard. Navigate to the Loans section to manage your loans. + </p> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/(main)/layout.tsx b/frontend/src/app/(main)/layout.tsx new file mode 100644 index 0000000..846a118 --- /dev/null +++ b/frontend/src/app/(main)/layout.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { authApi, userApi } from '@/lib/api'; +import { ThemeToggle } from '@/components/shared/ThemeToggle'; + + +export default function MainLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const router = useRouter(); + const [userName, setUserName] = useState<string>(''); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Check if user is authenticated + const token = localStorage.getItem('token'); + if (!token) { + router.push('/login'); + return; + } + + // Fetch user profile + userApi.getProfile() + .then(data => { + setUserName(data.user.Name); + setIsLoading(false); + }) + .catch(() => { + // If error fetching profile, redirect to login + router.push('/login'); + }); + }, [router]); + + const handleLogout = () => { + authApi.logout(); + }; + + if (isLoading) { + return <div className="flex h-screen items-center justify-center">Loading...</div>; + } + + return ( + <div className="min-h-screen bg-background"> + {/* Top Navigation */} + <header className="border-b"> + <div className="w-full flex h-16 items-center px-4"> + <div className="flex items-center gap-4"> + <div className="font-semibold text-xl">Finance Management</div> + </div> + + <div className="flex items-center ms-auto gap-4"> + <span className='text-sm font-medium'>Welcome!, {userName}</span> + <ThemeToggle /> + <Button variant="ghost" size="sm" onClick={handleLogout}> + Logout + </Button> + </div> + </div> + </header> + + {/* Main Content */} + <div className="container flex flex-row"> + {/* Sidebar */} + <aside className="w-64 pt-8 pr-6"> + <nav className="space-y-2"> + <Link href="/dashboard" className="block p-2 rounded-md hover:bg-muted"> + Dashboard + </Link> + <Link href="/loans" className="block p-2 rounded-md hover:bg-muted"> + Loans + </Link> + <Link href="/goals" className="block p-2 rounded-md hover:bg-muted"> + Goals + </Link> + <Link href="/settings" className="block p-2 rounded-md hover:bg-muted"> + Settings + </Link> + </nav> + </aside> + + {/* Page Content */} + <main className="flex-1 p-8"> + {children} + </main> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/(main)/loans/page.tsx b/frontend/src/app/(main)/loans/page.tsx new file mode 100644 index 0000000..07c1428 --- /dev/null +++ b/frontend/src/app/(main)/loans/page.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '@/components/ui/table'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { loanApi, Loan, LoanInput } from '@/lib/api'; + +export default function LoansPage() { + const queryClient = useQueryClient(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [selectedLoan, setSelectedLoan] = useState<Loan | null>(null); + + // Form state + const [loanName, setLoanName] = useState(''); + const [originalAmount, setOriginalAmount] = useState(''); + const [currentBalance, setCurrentBalance] = useState(''); + const [interestRate, setInterestRate] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [formError, setFormError] = useState(''); + + // Query loans + const { data, isLoading, error } = useQuery({ + queryKey: ['loans'], + queryFn: async () => { + const response = await loanApi.getLoans(); + return response.loans as Loan[]; + } + }); + + // Create loan mutation + const createLoanMutation = useMutation({ + mutationFn: (loanData: LoanInput) => loanApi.createLoan(loanData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['loans'] }); + resetForm(); + setIsAddDialogOpen(false); + }, + onError: (error: Error) => { + setFormError(error.message); + } + }); + + // Delete loan mutation + const deleteLoanMutation = useMutation({ + mutationFn: (id: number) => loanApi.deleteLoan(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['loans'] }); + } + }); + + const resetForm = () => { + setLoanName(''); + setOriginalAmount(''); + setCurrentBalance(''); + setInterestRate(''); + setStartDate(''); + setEndDate(''); + setFormError(''); + setSelectedLoan(null); + }; + + const handleCreateLoan = (e: React.FormEvent) => { + e.preventDefault(); + setFormError(''); + + try { + // Validate inputs + if (!loanName || !originalAmount || !currentBalance || !startDate || !endDate) { + setFormError('Please fill in all required fields'); + return; + } + + const loanData: LoanInput = { + name: loanName, + originalAmount: Number(originalAmount) * 100, // Convert to cents + currentBalance: Number(currentBalance) * 100, // Convert to cents + interestRate: Number(interestRate) || 0, + startDate, + endDate, + }; + + createLoanMutation.mutate(loanData); + } catch (err: any) { + setFormError(err.message || 'Error creating loan'); + } + }; + + const handleDeleteLoan = (id: number) => { + if (window.confirm('Are you sure you want to delete this loan?')) { + deleteLoanMutation.mutate(id); + } + }; + + // Format currency (convert cents to dollars) + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount / 100); + }; + + // Format date + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(); + }; + + if (isLoading) { + return <div>Loading loans...</div>; + } + + if (error) { + return <div>Error loading loans: {error.toString()}</div>; + } + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <h1 className="text-3xl font-bold">Loans</h1> + <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> + <DialogTrigger asChild> + <Button onClick={resetForm}>Add New Loan</Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Add New Loan</DialogTitle> + </DialogHeader> + <form onSubmit={handleCreateLoan} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="name">Loan Name</Label> + <Input + id="name" + placeholder="e.g., Car Loan" + value={loanName} + onChange={(e) => setLoanName(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="originalAmount">Original Amount ($)</Label> + <Input + id="originalAmount" + type="number" + step="0.01" + placeholder="10000.00" + value={originalAmount} + onChange={(e) => setOriginalAmount(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="currentBalance">Current Balance ($)</Label> + <Input + id="currentBalance" + type="number" + step="0.01" + placeholder="9500.00" + value={currentBalance} + onChange={(e) => setCurrentBalance(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="interestRate">Interest Rate (%)</Label> + <Input + id="interestRate" + type="number" + step="0.01" + placeholder="5.25" + value={interestRate} + onChange={(e) => setInterestRate(e.target.value)} + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="startDate">Start Date</Label> + <Input + id="startDate" + type="date" + value={startDate} + onChange={(e) => setStartDate(e.target.value)} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="endDate">End Date</Label> + <Input + id="endDate" + type="date" + value={endDate} + onChange={(e) => setEndDate(e.target.value)} + required + /> + </div> + + {formError && ( + <div className="text-sm text-red-500"> + {formError} + </div> + )} + + <div className="flex justify-end space-x-2"> + <Button type="button" variant="outline" onClick={() => setIsAddDialogOpen(false)}> + Cancel + </Button> + <Button type="submit" disabled={createLoanMutation.isPending}> + {createLoanMutation.isPending ? 'Saving...' : 'Save Loan'} + </Button> + </div> + </form> + </DialogContent> + </Dialog> + </div> + + {data && data.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle>Your Loans</CardTitle> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Original Amount</TableHead> + <TableHead>Current Balance</TableHead> + <TableHead>Interest Rate</TableHead> + <TableHead>Start Date</TableHead> + <TableHead>End Date</TableHead> + <TableHead>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.map((loan) => ( + <TableRow key={loan.ID}> + <TableCell className="font-medium">{loan.Name}</TableCell> + <TableCell>{formatCurrency(loan.OriginalAmount)}</TableCell> + <TableCell>{formatCurrency(loan.CurrentBalance)}</TableCell> + <TableCell>{loan.InterestRate}%</TableCell> + <TableCell>{formatDate(loan.StartDate)}</TableCell> + <TableCell>{formatDate(loan.EndDate)}</TableCell> + <TableCell> + <div className="flex items-center space-x-2"> + <Button + variant="destructive" + size="sm" + onClick={() => handleDeleteLoan(loan.ID)} + disabled={deleteLoanMutation.isPending} + > + Delete + </Button> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + ) : ( + <Card> + <CardContent className="flex flex-col items-center justify-center p-10"> + <p className="text-muted-foreground mb-4">You don't have any loans yet.</p> + <Button onClick={() => setIsAddDialogOpen(true)}>Add Your First Loan</Button> + </CardContent> + </Card> + )} + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico Binary files differnew file mode 100644 index 0000000..718d6fe --- /dev/null +++ b/frontend/src/app/favicon.ico diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..dc98be7 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..f746389 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; +import { ThemeProvider } from "@/components/shared/ThemeProvider"; +import { NotificationProvider } from "@/components/shared/NotificationContext"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Finance Management", + description: "Enhanced Personal Finance Management Application", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <html lang="en" suppressHydrationWarning> + <body + className={`${geistSans.variable} ${geistMono.variable} antialiased`} + > + <ThemeProvider> + <Providers> + <NotificationProvider> + {children} + </NotificationProvider> + </Providers> + </ThemeProvider> + </body> + </html> + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..b2f055e --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from "next/image"; + +export default function Home() { + const router = useRouter(); + + useEffect(() => { + // Check if user is authenticated + const token = localStorage.getItem('token'); + if (token) { + // If authenticated, redirect to dashboard + router.push('/dashboard'); + } else { + // Otherwise, redirect to login + router.push('/login'); + } + }, [router]); + + return ( + <div className="flex min-h-screen items-center justify-center"> + <p>Redirecting...</p> + </div> + ); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..efedc0b --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, + })); + + return ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ); +}
\ No newline at end of file diff --git a/frontend/src/components/shared/Notification.tsx b/frontend/src/components/shared/Notification.tsx new file mode 100644 index 0000000..bcc11c4 --- /dev/null +++ b/frontend/src/components/shared/Notification.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CheckCircle, AlertCircle, X } from 'lucide-react'; + +export type NotificationType = 'success' | 'error' | 'info'; + +interface NotificationProps { + type: NotificationType; + message: string; + duration?: number; + onClose?: () => void; +} + +export function Notification({ + type, + message, + duration = 5000, // Default duration of 5 seconds + onClose +}: NotificationProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + setIsVisible(false); + if (onClose) onClose(); + }, duration); + + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + const handleClose = () => { + setIsVisible(false); + if (onClose) onClose(); + }; + + if (!isVisible) return null; + + const getIcon = () => { + switch (type) { + case 'success': + return <CheckCircle className="h-5 w-5 text-green-500" />; + case 'error': + return <AlertCircle className="h-5 w-5 text-red-500" />; + case 'info': + return <AlertCircle className="h-5 w-5 text-blue-500" />; + default: + return null; + } + }; + + const getContainerClasses = () => { + const baseClasses = 'fixed top-4 right-4 z-50 flex items-center gap-3 rounded-lg p-4 shadow-md max-w-sm'; + + switch (type) { + case 'success': + return `${baseClasses} bg-green-50 text-green-800 border border-green-200`; + case 'error': + return `${baseClasses} bg-red-50 text-red-800 border border-red-200`; + case 'info': + return `${baseClasses} bg-blue-50 text-blue-800 border border-blue-200`; + default: + return baseClasses; + } + }; + + return ( + <div className={getContainerClasses()}> + {getIcon()} + <div className="flex-1">{message}</div> + <button + onClick={handleClose} + className="text-gray-500 hover:text-gray-700 focus:outline-none" + aria-label="Close notification" + > + <X className="h-4 w-4" /> + </button> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/components/shared/NotificationContext.tsx b/frontend/src/components/shared/NotificationContext.tsx new file mode 100644 index 0000000..a8f6454 --- /dev/null +++ b/frontend/src/components/shared/NotificationContext.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode } from 'react'; +import { Notification, NotificationType } from './Notification'; + +type NotificationData = { + id: string; + type: NotificationType; + message: string; + duration?: number; +}; + +interface NotificationContextType { + showNotification: (type: NotificationType, message: string, duration?: number) => void; + hideNotification: (id: string) => void; +} + +const NotificationContext = createContext<NotificationContextType | undefined>(undefined); + +export function NotificationProvider({ children }: { children: ReactNode }) { + const [notifications, setNotifications] = useState<NotificationData[]>([]); + + const showNotification = (type: NotificationType, message: string, duration?: number) => { + const id = Math.random().toString(36).substring(2, 9); + setNotifications(prev => [...prev, { id, type, message, duration }]); + return id; + }; + + const hideNotification = (id: string) => { + setNotifications(prev => prev.filter(notification => notification.id !== id)); + }; + + return ( + <NotificationContext.Provider value={{ showNotification, hideNotification }}> + {children} + {notifications.map(notification => ( + <Notification + key={notification.id} + type={notification.type} + message={notification.message} + duration={notification.duration} + onClose={() => hideNotification(notification.id)} + /> + ))} + </NotificationContext.Provider> + ); +} + +export function useNotification() { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + return context; +}
\ No newline at end of file diff --git a/frontend/src/components/shared/ThemeProvider.tsx b/frontend/src/components/shared/ThemeProvider.tsx new file mode 100644 index 0000000..2fc67d9 --- /dev/null +++ b/frontend/src/components/shared/ThemeProvider.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState<Theme>('system'); + + useEffect(() => { + // Get the theme preference from localStorage if available + const storedTheme = localStorage.getItem('theme') as Theme | null; + if (storedTheme) { + setTheme(storedTheme); + } + }, []); + + useEffect(() => { + const root = window.document.documentElement; + + // Remove all theme classes + root.classList.remove('light', 'dark'); + + // Add the appropriate class based on theme + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + } else { + root.classList.add(theme); + } + + // Store the preference + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + <ThemeContext.Provider value={{ theme, setTheme }}> + {children} + </ThemeContext.Provider> + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}
\ No newline at end of file diff --git a/frontend/src/components/shared/ThemeToggle.tsx b/frontend/src/components/shared/ThemeToggle.tsx new file mode 100644 index 0000000..679bbc5 --- /dev/null +++ b/frontend/src/components/shared/ThemeToggle.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTheme } from './ThemeProvider'; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const toggleTheme = () => { + if (theme === 'dark') { + setTheme('light'); + } else { + setTheme('dark'); + } + }; + + return ( + <Button + variant="ghost" + size="icon" + onClick={toggleTheme} + aria-label={ + theme === 'dark' + ? 'Switch to light theme' + : 'Switch to dark theme' + } + > + {theme === 'dark' ? ( + <Sun className="h-[1.2rem] w-[1.2rem]" /> + ) : ( + <Moon className="h-[1.2rem] w-[1.2rem]" /> + )} + </Button> + ); +}
\ No newline at end of file diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card" + className={cn( + "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", + className + )} + {...props} + /> + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn("leading-none font-semibold", className)} + {...props} + /> + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-action" + className={cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + className + )} + {...props} + /> + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-content" + className={cn("px-6", className)} + {...props} + /> + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn("flex items-center px-6 [.border-t]:pt-6", className)} + {...props} + /> + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..7d7a9d3 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", + className + )} + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content>) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + "bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} + {...props} + /> + ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className + )} + {...props} + /> + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-lg leading-none font-semibold", className)} + {...props} + /> + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div + data-slot="form-item" + className={cn("grid gap-2", className)} + {...props} + /> + </FormItemContext.Provider> + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + const { error, formItemId } = useFormField() + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn("data-[error=true]:text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +} + +function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +} + +function FormDescription({ className, ...props }: React.ComponentProps<"p">) { + const { formDescriptionId } = useFormField() + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function FormMessage({ className, ...props }: React.ComponentProps<"p">) { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message ?? "") : props.children + + if (!body) { + return null + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn("text-destructive text-sm", className)} + {...props} + > + {body} + </p> + ) +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + className + )} + {...props} + /> + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} + /> + ) +} + +export { Label } diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( + <div + data-slot="table-container" + className="relative w-full overflow-x-auto" + > + <table + data-slot="table" + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + <thead + data-slot="table-header" + className={cn("[&_tr]:border-b", className)} + {...props} + /> + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + <tbody + data-slot="table-body" + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + <tfoot + data-slot="table-footer" + className={cn( + "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + <tr + data-slot="table-row" + className={cn( + "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...props} + /> + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + <th + data-slot="table-head" + className={cn( + "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + <td + data-slot="table-cell" + className={cn( + "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( + <caption + data-slot="table-caption" + className={cn("text-muted-foreground mt-4 text-sm", className)} + {...props} + /> + ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..35ef8d6 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,123 @@ +// 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 = { + '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}`); + } + + 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; + }, + + 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(); + }, + + logout: () => { + localStorage.removeItem('token'); + window.location.href = '/login'; + } +}; + +// 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; +} + +export interface LoanInput { + name: string; + originalAmount: number; + currentBalance: number; + interestRate: number; + startDate: string; + endDate: string; + accountId?: number; +} + +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' + }) +}; + +// User API +export const userApi = { + getProfile: () => fetchWithAuth('/users/me') +};
\ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} |