aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/app/(auth)/layout.tsx36
-rw-r--r--frontend/src/app/(auth)/login/page.tsx88
-rw-r--r--frontend/src/app/(auth)/signup/page.tsx116
-rw-r--r--frontend/src/app/(main)/dashboard/page.tsx10
-rw-r--r--frontend/src/app/(main)/layout.tsx94
-rw-r--r--frontend/src/app/(main)/loans/page.tsx279
-rw-r--r--frontend/src/app/favicon.icobin0 -> 25931 bytes
-rw-r--r--frontend/src/app/globals.css122
-rw-r--r--frontend/src/app/layout.tsx43
-rw-r--r--frontend/src/app/page.tsx27
-rw-r--r--frontend/src/app/providers.tsx25
-rw-r--r--frontend/src/components/shared/Notification.tsx82
-rw-r--r--frontend/src/components/shared/NotificationContext.tsx55
-rw-r--r--frontend/src/components/shared/ThemeProvider.tsx56
-rw-r--r--frontend/src/components/shared/ThemeToggle.tsx36
-rw-r--r--frontend/src/components/ui/button.tsx59
-rw-r--r--frontend/src/components/ui/card.tsx92
-rw-r--r--frontend/src/components/ui/dialog.tsx135
-rw-r--r--frontend/src/components/ui/form.tsx167
-rw-r--r--frontend/src/components/ui/input.tsx21
-rw-r--r--frontend/src/components/ui/label.tsx24
-rw-r--r--frontend/src/components/ui/table.tsx116
-rw-r--r--frontend/src/lib/api.ts123
-rw-r--r--frontend/src/lib/utils.ts6
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&apos;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&apos;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
new file mode 100644
index 0000000..718d6fe
--- /dev/null
+++ b/frontend/src/app/favicon.ico
Binary files differ
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))
+}