# Create new React app with TypeScript
npx create-react-app my-app --template typescript
cd my-app
# Or using Vite (recommended for better performance)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install# Install Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Install shadcn/ui
npx shadcn-ui@latest init
# Install additional React hooks and utilities
npm install react-router-dom @types/react-router-dom
npm install axios react-query @tanstack/react-query// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}src/
├── components/
│ ├── ui/ # shadcn/ui components
│ ├── common/ # Reusable components
│ └── forms/ # Form components
├── hooks/ # Custom React hooks
├── context/ # React Context providers
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── pages/ # Route components
├── services/ # API calls
└── constants/ # App constants// src/types/index.ts
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}
export interface AppState {
user: User | null;
isLoading: boolean;
theme: 'light' | 'dark';
}// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/hooks/*": ["src/hooks/*"],
"@/types/*": ["src/types/*"]
}
}
}// src/context/AppContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { AppState, User } from '@/types';
interface AppContextType {
state: AppState;
dispatch: React.Dispatch<AppAction>;
login: (user: User) => void;
logout: () => void;
toggleTheme: () => void;
}
type AppAction =
| { type: 'SET_USER'; payload: User | null }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'TOGGLE_THEME' };
const initialState: AppState = {
user: null,
isLoading: false,
theme: 'light'
};
const AppContext = createContext<AppContextType | undefined>(undefined);
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'TOGGLE_THEME':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
default:
return state;
}
}
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
const login = (user: User) => {
dispatch({ type: 'SET_USER', payload: user });
};
const logout = () => {
dispatch({ type: 'SET_USER', payload: null });
};
const toggleTheme = () => {
dispatch({ type: 'TOGGLE_THEME' });
};
return (
<AppContext.Provider value={{
state,
dispatch,
login,
logout,
toggleTheme
}}>
{children}
</AppContext.Provider>
);
}
export function useAppContext() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within AppProvider');
}
return context;
}// src/context/ThemeContext.tsx
import React, { createContext, useContext, useEffect } from 'react';
import { useAppContext } from './AppContext';
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggleTheme: () => void;
} | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { state, toggleTheme } = useAppContext();
useEffect(() => {
document.documentElement.classList.toggle('dark', state.theme === 'dark');
}, [state.theme]);
return (
<ThemeContext.Provider value={{ theme: state.theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}// src/hooks/useApi.ts
import { useState, useEffect } from 'react';
import { ApiResponse } from '@/types';
export function useApi<T>(
apiFunction: () => Promise<ApiResponse<T>>,
dependencies: any[] = []
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await apiFunction();
setData(response.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, dependencies);
return { data, loading, error, refetch: () => fetchData() };
}// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}# Install common components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add toast// src/components/ui/LoadingSpinner.tsx
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
};
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-gray-300 border-t-blue-600',
sizeClasses[size],
className
)}
/>
);
}// src/components/common/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold text-red-600">Something went wrong!</h2>
<p className="mt-2 text-gray-600">{this.state.error?.message}</p>
<button
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => this.setState({ hasError: false })}
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}npm install react-hook-form @hookform/resolvers zod// src/components/forms/ContactForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters')
});
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactForm() {
const form = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: '',
email: '',
message: ''
}
});
const onSubmit = async (data: ContactFormData) => {
try {
// Handle form submission
console.log('Form data:', data);
// Reset form after successful submission
form.reset();
} catch (error) {
console.error('Form submission error:', error);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter your message"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</form>
</Form>
);
}// src/services/api.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { ApiResponse } from '@/types';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
this.client.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized access
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
async get<T>(url: string): Promise<ApiResponse<T>> {
const response = await this.client.get<ApiResponse<T>>(url);
return response.data;
}
async post<T>(url: string, data: any): Promise<ApiResponse<T>> {
const response = await this.client.post<ApiResponse<T>>(url, data);
return response.data;
}
async put<T>(url: string, data: any): Promise<ApiResponse<T>> {
const response = await this.client.put<ApiResponse<T>>(url, data);
return response.data;
}
async delete<T>(url: string): Promise<ApiResponse<T>> {
const response = await this.client.delete<ApiResponse<T>>(url);
return response.data;
}
}
export const apiClient = new ApiClient();// src/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/services/api';
import { User } from '@/types';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => apiClient.get<User[]>('/users'),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: Omit<User, 'id'>) =>
apiClient.post<User>('/users', userData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}/* src/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}// src/pages/LazyPages.tsx
import { lazy } from 'react';
export const HomePage = lazy(() => import('./HomePage'));
export const AboutPage = lazy(() => import('./AboutPage'));
export const ContactPage = lazy(() => import('./ContactPage'));
export const UserProfile = lazy(() => import('./UserProfile'));// src/components/UserCard.tsx
import React, { memo } from 'react';
import { User } from '@/types';
interface UserCardProps {
user: User;
onEdit: (userId: string) => void;
onDelete: (userId: string) => void;
}
export const UserCard = memo(({ user, onEdit, onDelete }: UserCardProps) => {
return (
<div className="p-4 border rounded-lg">
<h3 className="font-semibold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<div className="mt-2 space-x-2">
<button
onClick={() => onEdit(user.id)}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Edit
</button>
<button
onClick={() => onDelete(user.id)}
className="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600"
>
Delete
</button>
</div>
</div>
);
});
UserCard.displayName = 'UserCard';// src/utils/errorTracking.ts
export function logError(error: Error, context?: string) {
// In production, you'd send this to a service like Sentry
console.error('Error:', error);
if (context) {
console.error('Context:', context);
}
// Example: Send to error tracking service
// Sentry.captureException(error, { extra: { context } });
}npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event// src/components/__tests__/UserCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UserCard } from '../UserCard';
import { User } from '@/types';
const mockUser: User = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
};
describe('UserCard', () => {
it('renders user information', () => {
const mockOnEdit = jest.fn();
const mockOnDelete = jest.fn();
render(
<UserCard
user={mockUser}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('calls onEdit when edit button is clicked', () => {
const mockOnEdit = jest.fn();
const mockOnDelete = jest.fn();
render(
<UserCard
user={mockUser}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(mockOnEdit).toHaveBeenCalledWith('1');
});
});npm install -D eslint prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser// .eslintrc.json
{
"extends": [
"react-app",
"react-app/jest",
"@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/exhaustive-deps": "warn"
}
}// src/patterns/RenderProps.tsx
interface RenderPropsExample<T> {
data: T;
loading: boolean;
error: string | null;
children: (props: { data: T; loading: boolean; error: string | null }) => React.ReactNode;
}
export function DataProvider<T>({ data, loading, error, children }: RenderPropsExample<T>) {
return <>{children({ data, loading, error })}</>;
}
// Usage
function UserList() {
const { data: users, loading, error } = useUsers();
return (
<DataProvider data={users} loading={loading} error={error}>
{({ data, loading, error }) => {
if (loading) return <LoadingSpinner />;
if (error) return <div>Error: {error}</div>;
return (
<div>
{data?.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}}
</DataProvider>
);
}// src/hocs/withAuth.tsx
import React, { ComponentType } from 'react';
import { useAppContext } from '@/context/AppContext';
import { Navigate } from 'react-router-dom';
interface WithAuthProps {
// Any additional props the HOC might need
}
export function withAuth<P extends object>(
WrappedComponent: ComponentType<P>
) {
const AuthenticatedComponent = (props: P & WithAuthProps) => {
const { state } = useAppContext();
if (!state.user) {
return <Navigate to="/login" replace />;
}
return <WrappedComponent {...props} />;
};
AuthenticatedComponent.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`;
return AuthenticatedComponent;
}
// Usage
export const ProtectedDashboard = withAuth(Dashboard);// src/hooks/useUserActions.ts
import { useCallback } from 'react';
import { useAppContext } from '@/context/AppContext';
import { useCreateUser, useUpdateUser, useDeleteUser } from './useUsers';
import { User } from '@/types';
export function useUserActions() {
const { state } = useAppContext();
const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser();
const deleteUserMutation = useDeleteUser();
const createUser = useCallback(async (userData: Omit<User, 'id'>) => {
try {
await createUserMutation.mutateAsync(userData);
// Handle success (show toast, etc.)
} catch (error) {
// Handle error
console.error('Failed to create user:', error);
}
}, [createUserMutation]);
const updateUser = useCallback(async (userId: string, userData: Partial<User>) => {
try {
await updateUserMutation.mutateAsync({ id: userId, ...userData });
} catch (error) {
console.error('Failed to update user:', error);
}
}, [updateUserMutation]);
const deleteUser = useCallback(async (userId: string) => {
if (window.confirm('Are you sure you want to delete this user?')) {
try {
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error('Failed to delete user:', error);
}
}
}, [deleteUserMutation]);
return {
createUser,
updateUser,
deleteUser,
isLoading: createUserMutation.isPending || updateUserMutation.isPending || deleteUserMutation.isPending,
currentUser: state.user
};
}// src/components/VirtualList.tsx
import React, { useState, useMemo, useCallback } from 'react';
import { FixedSizeList as List } from 'react-window';
interface VirtualListProps<T> {
items: T[];
height: number;
itemHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
export function VirtualList<T>({
items,
height,
itemHeight,
renderItem
}: VirtualListProps<T>) {
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{renderItem(items[index], index)}
</div>
), [items, renderItem]);
return (
<List
height={height}
itemCount={items.length}
itemSize={itemHeight}
width="100%"
>
{Row}
</List>
);
}// src/types/routes.ts
export interface RouteParams {
userId?: string;
postId?: string;
}
export interface LocationState {
from?: string;
message?: string;
}// src/router/AppRouter.tsx
import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Layout } from '@/components/layout/Layout';
import { HomePage, AboutPage, ContactPage, UserProfile } from '@/pages/LazyPages';
import { withAuth } from '@/hocs/withAuth';
const ProtectedUserProfile = withAuth(UserProfile);
export function AppRouter() {
return (
<BrowserRouter>
<ErrorBoundary>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={
<Suspense fallback={<LoadingSpinner />}>
<HomePage />
</Suspense>
} />
<Route path="about" element={
<Suspense fallback={<LoadingSpinner />}>
<AboutPage />
</Suspense>
} />
<Route path="contact" element={
<Suspense fallback={<LoadingSpinner />}>
<ContactPage />
</Suspense>
} />
<Route path="users/:userId" element={
<Suspense fallback={<LoadingSpinner />}>
<ProtectedUserProfile />
</Suspense>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}// src/components/layout/Layout.tsx
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Header } from './Header';
import { Footer } from './Footer';
import { Sidebar } from './Sidebar';
export function Layout() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
<Footer />
</div>
);
}// src/components/layout/Header.tsx
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/context/AppContext';
import { useTheme } from '@/context/ThemeContext';
export function Header() {
const { state, logout } = useAppContext();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
return (
<header className="bg-white dark:bg-gray-800 shadow-md">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link to="/" className="text-2xl font-bold text-primary">
MyApp
</Link>
<nav className="hidden md:flex space-x-6">
<Link to="/" className="hover:text-primary transition-colors">
Home
</Link>
<Link to="/about" className="hover:text-primary transition-colors">
About
</Link>
<Link to="/contact" className="hover:text-primary transition-colors">
Contact
</Link>
</nav>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="p-2"
>
{theme === 'light' ? '🌙' : '☀️'}
</Button>
{state.user ? (
<div className="flex items-center space-x-2">
<span className="text-sm">Hello, {state.user.name}</span>
<Button variant="outline" size="sm" onClick={handleLogout}>
Logout
</Button>
</div>
) : (
<Link to="/login">
<Button size="sm">Login</Button>
</Link>
)}
</div>
</div>
</header>
);
}// src/utils/validation.ts
import DOMPurify from 'dompurify';
export function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] });
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password: string): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/(?=.*[a-z])/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/(?=.*[A-Z])/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/(?=.*\d)/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/(?=.*[@$!%*?&])/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors
};
}// src/config/environment.ts
interface Config {
apiUrl: string;
environment: 'development' | 'staging' | 'production';
enableDebug: boolean;
features: {
analytics: boolean;
errorTracking: boolean;
};
}
function getConfig(): Config {
const environment = (process.env.NODE_ENV as Config['environment']) || 'development';
return {
apiUrl: process.env.REACT_APP_API_URL || 'http://localhost:3001',
environment,
enableDebug: environment === 'development',
features: {
analytics: process.env.REACT_APP_ENABLE_ANALYTICS === 'true',
errorTracking: process.env.REACT_APP_ENABLE_ERROR_TRACKING === 'true'
}
};
}
export const config = getConfig();// src/utils/security.ts
export function setupCSP() {
// In a real app, this would be set via HTTP headers
const meta = document.createElement('meta');
meta.httpEquiv = 'Content-Security-Policy';
meta.content = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.example.com",
"font-src 'self'"
].join('; ');
document.head.appendChild(meta);
}// src/hooks/useMediaQuery.ts
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
// Usage
export function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
const isDesktop = useMediaQuery('(min-width: 1025px)');
return (
<div className={`
${isMobile ? 'px-4' : ''}
${isTablet ? 'px-8' : ''}
${isDesktop ? 'px-12' : ''}
`}>
{isMobile && <MobileView />}
{isTablet && <TabletView />}
{isDesktop && <DesktopView />}
</div>
);
}// src/components/accessible/SkipLink.tsx
import React from 'react';
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
bg-primary text-primary-foreground px-4 py-2 rounded-md z-50"
>
Skip to main content
</a>
);
}// src/hooks/useAnnouncement.ts
import { useCallback } from 'react';
export function useAnnouncement() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', priority);
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}, []);
return { announce };
}// src/hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react';
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTabKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleTabKeyPress);
firstElement?.focus();
return () => {
document.removeEventListener('keydown', handleTabKeyPress);
};
}, [isActive]);
return containerRef;
}// package.json - Add build scripts
{
"scripts": {
"build": "react-scripts build",
"build:analyze": "npm run build && npx bundle-analyzer build/static/js/*.js",
"build:staging": "REACT_APP_ENV=staging npm run build",
"build:production": "REACT_APP_ENV=production npm run build"
}
}# Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]# nginx.conf
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run test -- --coverage --watchAll=false
- run: npm run build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Deploy to production
run: |
# Your deployment commands here
echo "Deploying to production..."// src/App.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { AppProvider } from '@/context/AppContext';
import { ThemeProvider } from '@/context/ThemeContext';
import { AppRouter } from '@/router/AppRouter';
import { Toaster } from '@/components/ui/toaster';
import { SkipLink } from '@/components/accessible/SkipLink';
import { config } from '@/config/environment';
import './globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 3,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>
<ThemeProvider>
<SkipLink />
<AppRouter />
<Toaster />
{config.enableDebug && <ReactQueryDevtools />}
</ThemeProvider>
</AppProvider>
</QueryClientProvider>
);
}
export default App;// src/types/expense.ts
export interface User {
id: number;
email: string;
full_name: string;
department: string;
created_at: string;
updated_at: string;
}
export interface Category {
id: number;
name: string;
description: string;
created_at: string;
updated_at: string;
}
export interface Expense {
id: number;
amount: number;
description: string;
expense_date: string;
merchant_name?: string;
receipt_number?: string;
category_id: number;
user_id: number;
created_at: string;
updated_at: string;
category?: Category;
}
export interface Budget {
id: number;
amount: number;
month: number;
year: number;
category_id: number;
user_id: number;
category?: Category;
}
export interface ExpenseCreate {
amount: number;
description: string;
expense_date: string;
merchant_name?: string;
receipt_number?: string;
category_id: number;
}
export interface BudgetCreate {
amount: number;
month: number;
year: number;
category_id: number;
}
export interface MonthlySummary {
month: number;
year: number;
total_amount: number;
total_expenses: number;
category_breakdown: Record<number, {
category_name: string;
total: number;
count: number;
}>;
}
export interface BudgetSummary {
month: number;
year: number;
total_budget: number;
total_spent: number;
remaining: number;
is_over_budget: boolean;
categories: Array<{
category_id: number;
category_name: string;
budget_amount: number;
spent_amount: number;
remaining: number;
percentage_used: number;
is_over_budget: boolean;
}>;
}// src/services/expenseApi.ts
import { apiClient } from './api';
import {
Expense,
ExpenseCreate,
Category,
Budget,
BudgetCreate,
MonthlySummary,
BudgetSummary
} from '@/types/expense';
export class ExpenseAPI {
// Expense operations
static async createExpense(data: ExpenseCreate): Promise<Expense> {
const response = await apiClient.post<Expense>('/expenses/', data);
return response.data;
}
static async getExpenses(params?: {
skip?: number;
limit?: number;
category_id?: number;
start_date?: string;
end_date?: string;
}): Promise<Expense[]> {
const queryParams = new URLSearchParams();
if (params?.skip) queryParams.append('skip', params.skip.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.category_id) queryParams.append('category_id', params.category_id.toString());
if (params?.start_date) queryParams.append('start_date', params.start_date);
if (params?.end_date) queryParams.append('end_date', params.end_date);
const response = await apiClient.get<Expense[]>(`/expenses/?${queryParams}`);
return response.data;
}
static async getExpense(id: number): Promise<Expense> {
const response = await apiClient.get<Expense>(`/expenses/${id}`);
return response.data;
}
static async updateExpense(id: number, data: Partial<ExpenseCreate>): Promise<Expense> {
const response = await apiClient.put<Expense>(`/expenses/${id}`, data);
return response.data;
}
static async deleteExpense(id: number): Promise<void> {
await apiClient.delete(`/expenses/${id}`);
}
static async getMonthlySummary(month: number, year: number): Promise<MonthlySummary> {
const response = await apiClient.get<MonthlySummary>(
`/expenses/summary/monthly?month=${month}&year=${year}`
);
return response.data;
}
// Category operations
static async getCategories(): Promise<Category[]> {
const response = await apiClient.get<Category[]>('/categories/');
return response.data;
}
static async createCategory(data: { name: string; description: string }): Promise<Category> {
const response = await apiClient.post<Category>('/categories/', data);
return response.data;
}
// Budget operations
static async createBudget(data: BudgetCreate): Promise<Budget> {
const response = await apiClient.post<Budget>('/budgets/', data);
return response.data;
}
static async getBudgetSummary(month: number, year: number): Promise<BudgetSummary> {
const response = await apiClient.get<BudgetSummary>(
`/budgets/summary?month=${month}&year=${year}`
);
return response.data;
}
static async updateBudget(id: number, amount: number): Promise<Budget> {
const response = await apiClient.put<Budget>(`/budgets/${id}?amount=${amount}`, {});
return response.data;
}
static async deleteBudget(id: number): Promise<void> {
await apiClient.delete(`/budgets/${id}`);
}
}// src/hooks/useExpenses.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ExpenseAPI } from '@/services/expenseApi';
import { Expense, ExpenseCreate } from '@/types/expense';
import { useToast } from '@/components/ui/use-toast';
export function useExpenses(filters?: {
category_id?: number;
start_date?: string;
end_date?: string;
skip?: number;
limit?: number;
}) {
return useQuery({
queryKey: ['expenses', filters],
queryFn: () => ExpenseAPI.getExpenses(filters),
staleTime: 30 * 1000, // 30 seconds
});
}
export function useExpense(id: number) {
return useQuery({
queryKey: ['expense', id],
queryFn: () => ExpenseAPI.getExpense(id),
enabled: !!id,
});
}
export function useCreateExpense() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (data: ExpenseCreate) => ExpenseAPI.createExpense(data),
onSuccess: (newExpense) => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
queryClient.invalidateQueries({ queryKey: ['monthly-summary'] });
queryClient.invalidateQueries({ queryKey: ['budget-summary'] });
toast({
title: "Success",
description: "Expense created successfully",
});
},
onError: (error: any) => {
toast({
title: "Error",
description: error.response?.data?.detail || "Failed to create expense",
variant: "destructive",
});
},
});
}
export function useUpdateExpense() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ExpenseCreate> }) =>
ExpenseAPI.updateExpense(id, data),
onSuccess: (updatedExpense) => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
queryClient.invalidateQueries({ queryKey: ['expense', updatedExpense.id] });
queryClient.invalidateQueries({ queryKey: ['monthly-summary'] });
toast({
title: "Success",
description: "Expense updated successfully",
});
},
onError: (error: any) => {
toast({
title: "Error",
description: error.response?.data?.detail || "Failed to update expense",
variant: "destructive",
});
},
});
}
export function useDeleteExpense() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (id: number) => ExpenseAPI.deleteExpense(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
queryClient.invalidateQueries({ queryKey: ['monthly-summary'] });
toast({
title: "Success",
description: "Expense deleted successfully",
});
},
onError: (error: any) => {
toast({
title: "Error",
description: error.response?.data?.detail || "Failed to delete expense",
variant: "destructive",
});
},
});
}
export function useMonthlySummary(month: number, year: number) {
return useQuery({
queryKey: ['monthly-summary', month, year],
queryFn: () => ExpenseAPI.getMonthlySummary(month, year),
enabled: !!month && !!year,
});
}// src/hooks/useBudgets.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ExpenseAPI } from '@/services/expenseApi';
import { BudgetCreate } from '@/types/expense';
import { useToast } from '@/components/ui/use-toast';
export function useBudgetSummary(month: number, year: number) {
return useQuery({
queryKey: ['budget-summary', month, year],
queryFn: () => ExpenseAPI.getBudgetSummary(month, year),
enabled: !!month && !!year,
});
}
export function useCreateBudget() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (data: BudgetCreate) => ExpenseAPI.createBudget(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-summary'] });
toast({
title: "Success",
description: "Budget created successfully",
});
},
onError: (error: any) => {
toast({
title: "Error",
description: error.response?.data?.detail || "Failed to create budget",
variant: "destructive",
});
},
});
}
// src/hooks/useCategories.ts
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: () => ExpenseAPI.getCategories(),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: (data: { name: string; description: string }) =>
ExpenseAPI.createCategory(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
toast({
title: "Success",
description: "Category created successfully",
});
},
onError: (error: any) => {
toast({
title: "Error",
description: error.response?.data?.detail || "Failed to create category",
variant: "destructive",
});
},
});
}// src/components/expenses/ExpenseForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useCategories } from '@/hooks/useCategories';
import { ExpenseCreate, Expense } from '@/types/expense';
import { cn } from '@/lib/utils';
const expenseSchema = z.object({
amount: z.number().min(0.01, 'Amount must be greater than 0'),
description: z.string().min(1, 'Description is required'),
expense_date: z.date({ required_error: 'Expense date is required' }),
merchant_name: z.string().optional(),
receipt_number: z.string().optional(),
category_id: z.number({ required_error: 'Category is required' }),
});
type ExpenseFormData = z.infer<typeof expenseSchema>;
interface ExpenseFormProps {
initialData?: Expense;
onSubmit: (data: ExpenseCreate) => void;
isLoading?: boolean;
}
export function ExpenseForm({ initialData, onSubmit, isLoading }: ExpenseFormProps) {
const { data: categories, isLoading: categoriesLoading } = useCategories();
const form = useForm<ExpenseFormData>({
resolver: zodResolver(expenseSchema),
defaultValues: {
amount: initialData?.amount || 0,
description: initialData?.description || '',
expense_date: initialData ? new Date(initialData.expense_date) : new Date(),
merchant_name: initialData?.merchant_name || '',
receipt_number: initialData?.receipt_number || '',
category_id: initialData?.category_id,
},
});
const handleSubmit = (data: ExpenseFormData) => {
const submitData: ExpenseCreate = {
...data,
expense_date: data.expense_date.toISOString(),
};
onSubmit(submitData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Category *</FormLabel>
<Select
onValueChange={(value) => field.onChange(parseInt(value))}
defaultValue={field.value?.toString()}
disabled={categoriesLoading}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories?.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="Describe the expense..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="expense_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Date *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!field.value && 'text-muted-foreground'
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date('1900-01-01')
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="merchant_name"
render={({ field }) => (
<FormItem>
<FormLabel>Merchant</FormLabel>
<FormControl>
<Input placeholder="Store/Restaurant name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="receipt_number"
render={({ field }) => (
<FormItem>
<FormLabel>Receipt #</FormLabel>
<FormControl>
<Input placeholder="Receipt number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-4">
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : initialData ? 'Update Expense' : 'Create Expense'}
</Button>
</div>
</form>
</Form>
);
}// src/components/expenses/ExpenseList.tsx
import React, { useState } from 'react';
import { format } from 'date-fns';
import { MoreHorizontal, Edit, Trash2, Filter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useExpenses, useDeleteExpense } from '@/hooks/useExpenses';
import { useCategories } from '@/hooks/useCategories';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Expense } from '@/types/expense';
interface ExpenseListProps {
onEdit: (expense: Expense) => void;
}
export function ExpenseList({ onEdit }: ExpenseListProps) {
const [filters, setFilters] = useState({
category_id: undefined as number | undefined,
start_date: '',
end_date: '',
search: '',
});
const [showFilters, setShowFilters] = useState(false);
const { data: expenses, isLoading, error } = useExpenses(filters);
const { data: categories } = useCategories();
const deleteExpenseMutation = useDeleteExpense();
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this expense?')) {
deleteExpenseMutation.mutate(id);
}
};
const filteredExpenses = expenses?.filter(expense => {
if (!filters.search) return true;
return expense.description.toLowerCase().includes(filters.search.toLowerCase()) ||
expense.merchant_name?.toLowerCase().includes(filters.search.toLowerCase());
});
if (isLoading) return <LoadingSpinner />;
if (error) return <div>Error loading expenses</div>;
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Expenses</h2>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
Filters
</Button>
</div>
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Filter Expenses</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium">Category</label>
<Select
value={filters.category_id?.toString() || ''}
onValueChange={(value) =>
setFilters(prev => ({
...prev,
category_id: value ? parseInt(value) : undefined
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All categories</SelectItem>
{categories?.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium">Start Date</label>
<Input
type="date"
value={filters.start_date}
onChange={(e) =>
setFilters(prev => ({ ...prev, start_date: e.target.value }))
}
/>
</div>
<div>
<label className="text-sm font-medium">End Date</label>
<Input
type="date"
value={filters.end_date}
onChange={(e) =>
setFilters(prev => ({ ...prev, end_date: e.target.value }))
}
/>
</div>
<div>
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Search description or merchant..."
value={filters.search}
onChange={(e) =>
setFilters(prev => ({ ...prev, search: e.target.value }))
}
/>
</div>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
<div className="grid gap-4">
{filteredExpenses?.map((expense) => (
<Card key={expense.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-medium">{expense.description}</h3>
<Badge variant="secondary">
{categories?.find(c => c.id === expense.category_id)?.name}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Amount:</span> ${expense.amount.toFixed(2)}
</div>
<div>
<span className="font-medium">Date:</span>{' '}
{format(new Date(expense.expense_date), 'MMM dd, yyyy')}
</div>
{expense.merchant_name && (
<div>
<span className="font-medium">Merchant:</span> {expense.merchant_name}
</div>
)}
{expense.receipt_number && (
<div>
<span className="font-medium">Receipt:</span> {expense.receipt_number}
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(expense)}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(expense.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
))}
</div>
{filteredExpenses?.length === 0 && (
<Card>
<CardContent className="p-8 text-center">
<p className="text-gray-500">No expenses found matching your criteria.</p>
</CardContent>
</Card>
)}
</div>
);
}// src/components/dashboard/Dashboard.tsx
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useMonthlySummary } from '@/hooks/useExpenses';
import { useBudgetSummary } from '@/hooks/useBudgets';
import { ExpenseChart } from './ExpenseChart';
import { BudgetProgress } from './BudgetProgress';
import { QuickStats } from './QuickStats';
import { RecentExpenses } from './RecentExpenses';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
export function Dashboard() {
const currentDate = new Date();
const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth() + 1);
const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear());
const { data: monthlySummary, isLoading: summaryLoading } = useMonthlySummary(
selectedMonth,
selectedYear
);
const { data: budgetSummary, isLoading: budgetLoading } = useBudgetSummary(
selectedMonth,
selectedYear
);
const months = [
{ value: 1, label: 'January' },
{ value: 2, label: 'February' },
{ value: 3, label: 'March' },
{ value: 4, label: 'April' },
{ value: 5, label: 'May' },
{ value: 6, label: 'June' },
{ value: 7, label: 'July' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'October' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' },
];
const years = Array.from({ length: 5 }, (_, i) => currentDate.getFullYear() - 2 + i);
if (summaryLoading || budgetLoading) {
return <LoadingSpinner />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="flex gap-4">
<Select
value={selectedMonth.toString()}
onValueChange={(value) => setSelectedMonth(parseInt(value))}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(parseInt(value))}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<QuickStats
monthlySummary={monthlySummary}
budgetSummary={budgetSummary}
month={selectedMonth}
year={selectedYear}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<ExpenseChart
categoryBreakdown={monthlySummary?.category_breakdown}
month={selectedMonth}
year={selectedYear}
/>
</div>
<div>
<BudgetProgress budgetSummary={budgetSummary} />
</div>
</div>
<RecentExpenses />
</div>
);
}// src/components/dashboard/QuickStats.tsx
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TrendingUp, TrendingDown, DollarSign, CreditCard } from 'lucide-react';
import { MonthlySummary, BudgetSummary } from '@/types/expense';
interface QuickStatsProps {
monthlySummary?: MonthlySummary;
budgetSummary?: BudgetSummary;
month: number;
year: number;
}
export function QuickStats({ monthlySummary, budgetSummary, month, year }: QuickStatsProps) {
const totalSpent = monthlySummary?.total_amount || 0;
const totalBudget = budgetSummary?.total_budget || 0;
const remaining = budgetSummary?.remaining || 0;
const isOverBudget = budgetSummary?.is_over_budget || false;
const stats = [
{
title: 'Total Spent',
value: `${totalSpent.toFixed(2)}`,
icon: DollarSign,
trend: isOverBudget ? 'down' : 'up',
color: isOverBudget ? 'text-red-600' : 'text-green-600',
},
{
title: 'Total Budget',
value: `${totalBudget.toFixed(2)}`,
icon: CreditCard,
trend: 'neutral',
color: 'text-blue-600',
},
{
title: 'Remaining',
value: `${remaining.toFixed(2)}`,
icon: remaining >= 0 ? TrendingUp : TrendingDown,
trend: remaining >= 0 ? 'up' : 'down',
color: remaining >= 0 ? 'text-green-600' : 'text-red-600',
},
{
title: 'Expenses Count',
value: monthlySummary?.total_expenses?.toString() || '0',
icon: CreditCard,
trend: 'neutral',
color: 'text-gray-600',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
{stat.trend !== 'neutral' && (
<p className={`text-xs ${stat.color} flex items-center mt-1`}>
{stat.trend === 'up' ? (
<TrendingUp className="mr-1 h-3 w-3" />
) : (
<TrendingDown className="mr-1 h-3 w-3" />
)}
{stat.trend === 'up' ? 'Within budget' : 'Over budget'}
</p>
)}
</CardContent>
</Card>
))}
</div>
);
}// src/components/dashboard/ExpenseChart.tsx
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
interface ExpenseChartProps {
categoryBreakdown?: Record<number, {
category_name: string;
total: number;
count: number;
}>;
month: number;
year: number;
}
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D'];
export function ExpenseChart({ categoryBreakdown, month, year }: ExpenseChartProps) {
const chartData = categoryBreakdown
? Object.entries(categoryBreakdown).map(([categoryId, data]) => ({
name: data.category_name,
amount: data.total,
count: data.count,
categoryId: parseInt(categoryId),
}))
: [];
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Expenses by Category</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Pie Chart */}
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="amount"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => [`${Number(value).toFixed(2)}`, 'Amount']} />
</PieChart>
</ResponsiveContainer>
</div>
{/* Bar Chart */}
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="name"
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis />
<Tooltip formatter={(value) => [`${Number(value).toFixed(2)}`, 'Amount']} />
<Bar dataKey="amount" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
No expense data available for this period
</div>
)}
</CardContent>
</Card>
{/* Category Details Table */}
{chartData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Category Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">Category</th>
<th className="text-right p-2">Amount</th>
<th className="text-right p-2">Count</th>
<th className="text-right p-2">Avg per Expense</th>
</tr>
</thead>
<tbody>
{chartData.map((item, index) => (
<tr key={item.categoryId} className="border-b">
<td className="p-2 flex items-center">
<div
className="w-4 h-4 rounded mr-2"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
{item.name}
</td>
<td className="text-right p-2">${item.amount.toFixed(2)}</td>
<td className="text-right p-2">{item.count}</td>
<td className="text-right p-2">
${(item.amount / item.count).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{viewType === 'category' && (
<Card>
<CardHeader>
<CardTitle>Category Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={categoryData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="category"
angle={-45}
textAnchor="end"
height={100}
/>
<YAxis />
<Tooltip formatter={(value) => [`${Number(value).toFixed(2)}`, 'Amount']} />
<Bar dataKey="total" fill="#8884d8" name="Total Spent" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Summary Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold">
${expenses?.reduce((sum, exp) => sum + exp.amount, 0).toFixed(2) || '0.00'}
</div>
<div className="text-sm text-gray-600">Total Spent</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold">{expenses?.length || 0}</div>
<div className="text-sm text-gray-600">Total Expenses</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold">
${expenses?.length ? (expenses.reduce((sum, exp) => sum + exp.amount, 0) / expenses.length).toFixed(2) : '0.00'}
</div>
<div className="text-sm text-gray-600">Average per Expense</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold">
{new Set(expenses?.map(exp => exp.category_id)).size || 0}
</div>
<div className="text-sm text-gray-600">Categories Used</div>
</CardContent>
</Card>
</div>
</div>
);
}// src/hooks/useNotifications.ts
import { useToast } from '@/components/ui/use-toast';
import { useCallback } from 'react';
export function useNotifications() {
const { toast } = useToast();
const notifyBudgetWarning = useCallback((categoryName: string, remaining: number) => {
toast({
title: "Budget Warning",
description: `You're close to your budget limit for ${categoryName}. ${remaining.toFixed(2)} remaining.`,
variant: "destructive",
});
}, [toast]);
const notifyBudgetExceeded = useCallback((categoryName: string, overage: number) => {
toast({
title: "Budget Exceeded",
description: `You've exceeded your budget for ${categoryName} by ${overage.toFixed(2)}.`,
variant: "destructive",
});
}, [toast]);
const notifyExpenseCreated = useCallback((amount: number) => {
toast({
title: "Expense Added",
description: `Expense of ${amount.toFixed(2)} has been recorded.`,
});
}, [toast]);
const notifyBudgetAlert = useCallback((summary: any) => {
// Check for budget alerts
summary.categories?.forEach((category: any) => {
if (category.is_over_budget) {
notifyBudgetExceeded(category.category_name, Math.abs(category.remaining));
} else if (category.percentage_used > 80) {
notifyBudgetWarning(category.category_name, category.remaining);
}
});
}, [notifyBudgetWarning, notifyBudgetExceeded]);
return {
notifyBudgetWarning,
notifyBudgetExceeded,
notifyExpenseCreated,
notifyBudgetAlert,
};
}// src/utils/errorHandling.ts
import { AxiosError } from 'axios';
export interface APIError {
message: string;
status: number;
details?: any;
}
export function handleAPIError(error: unknown): APIError {
if (error instanceof AxiosError) {
const status = error.response?.status || 500;
const data = error.response?.data;
// Handle FastAPI validation errors
if (status === 422 && data?.detail) {
if (Array.isArray(data.detail)) {
const messages = data.detail.map((err: any) =>
`${err.loc?.join(' → ') || 'Field'}: ${err.msg}`
).join(', ');
return {
message: `Validation Error: ${messages}`,
status,
details: data.detail,
};
}
}
// Handle business logic errors
if (data?.detail) {
return {
message: data.detail,
status,
details: data,
};
}
// Generic HTTP errors
switch (status) {
case 400:
return { message: 'Bad request. Please check your input.', status };
case 401:
return { message: 'Unauthorized. Please log in again.', status };
case 403:
return { message: 'Forbidden. You don\'t have permission for this action.', status };
case 404:
return { message: 'Resource not found.', status };
case 500:
return { message: 'Internal server error. Please try again later.', status };
default:
return { message: `HTTP Error ${status}`, status };
}
}
// Handle network errors
if (error instanceof Error) {
return {
message: error.message || 'An unexpected error occurred',
status: 0,
};
}
return {
message: 'An unknown error occurred',
status: 0,
};
}
// Enhanced API client with better error handling
// src/services/enhancedApi.ts
import { apiClient } from './api';
import { handleAPIError } from '@/utils/errorHandling';
export class EnhancedAPI {
static async safeRequest<T>(
requestFn: () => Promise<any>,
options?: {
successMessage?: string;
onSuccess?: (data: T) => void;
onError?: (error: APIError) => void;
}
): Promise<{ data?: T; error?: APIError }> {
try {
const response = await requestFn();
const data = response.data;
if (options?.onSuccess) {
options.onSuccess(data);
}
return { data };
} catch (error) {
const apiError = handleAPIError(error);
if (options?.onError) {
options.onError(apiError);
}
return { error: apiError };
}
}
}// src/utils/serviceWorker.ts
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
window.location.hostname === '[::1]' ||
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
const publicUrl = new URL(process.env.PUBLIC_URL!, window.location.href);
if (publicUrl.origin !== window.location.origin) {
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
checkValidServiceWorker(swUrl);
} else {
registerValidSW(swUrl);
}
});
}
}
function registerValidSW(swUrl: string) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
}
function checkValidServiceWorker(swUrl: string) {
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
registerValidSW(swUrl);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
// src/hooks/useOfflineSync.ts
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface OfflineAction {
id: string;
type: 'CREATE_EXPENSE' | 'UPDATE_EXPENSE' | 'DELETE_EXPENSE';
data: any;
timestamp: number;
}
export function useOfflineSync() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingActions, setPendingActions] = useState<OfflineAction[]>([]);
const queryClient = useQueryClient();
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
syncPendingActions();
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Load pending actions from localStorage
const stored = localStorage.getItem('pendingActions');
if (stored) {
setPendingActions(JSON.parse(stored));
}
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const addPendingAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = {
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
const updated = [...pendingActions, newAction];
setPendingActions(updated);
localStorage.setItem('pendingActions', JSON.stringify(updated));
};
const syncPendingActions = async () => {
if (pendingActions.length === 0) return;
try {
// Process pending actions
for (const action of pendingActions) {
await processPendingAction(action);
}
// Clear pending actions on success
setPendingActions([]);
localStorage.removeItem('pendingActions');
// Invalidate queries to refresh data
queryClient.invalidateQueries();
} catch (error) {
console.error('Failed to sync pending actions:', error);
}
};
const processPendingAction = async (action: OfflineAction) => {
// Implement actual API calls here
switch (action.type) {
case 'CREATE_EXPENSE':
// await ExpenseAPI.createExpense(action.data);
break;
case 'UPDATE_EXPENSE':
// await ExpenseAPI.updateExpense(action.data.id, action.data);
break;
case 'DELETE_EXPENSE':
// await ExpenseAPI.deleteExpense(action.data.id);
break;
}
};
return {
isOnline,
pendingActions,
addPendingAction,
syncPendingActions,
};
}// src/test/mocks/handlers.ts
import { rest } from 'msw';
import { Expense, Category, Budget } from '@/types/expense';
const mockCategories: Category[] = [
{ id: 1, name: 'Food & Dining', description: 'Restaurant and food expenses', created_at: '2024-01-01', updated_at: '2024-01-01' },
{ id: 2, name: 'Transportation', description: 'Car and travel expenses', created_at: '2024-01-01', updated_at: '2024-01-01' },
];
const mockExpenses: Expense[] = [
{
id: 1,
amount: 25.50,
description: 'Lunch at restaurant',
expense_date: '2024-03-15T12:30:00',
merchant_name: 'Pizza Palace',
receipt_number: 'R12345',
category_id: 1,
user_id: 1,
created_at: '2024-03-15T12:30:00',
updated_at: '2024-03-15T12:30:00',
},
];
export const handlers = [
// Categories
rest.get('/api/categories/', (req, res, ctx) => {
return res(ctx.json({ data: mockCategories }));
}),
rest.post('/api/categories/', (req, res, ctx) => {
const newCategory = {
id: mockCategories.length + 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...(req.body as any),
};
mockCategories.push(newCategory);
return res(ctx.status(201), ctx.json({ data: newCategory }));
}),
// Expenses
rest.get('/api/expenses/', (req, res, ctx) => {
const categoryId = req.url.searchParams.get('category_id');
let filteredExpenses = mockExpenses;
if (categoryId) {
filteredExpenses = mockExpenses.filter(
exp => exp.category_id === parseInt(categoryId)
);
}
return res(ctx.json({ data: filteredExpenses }));
}),
rest.post('/api/expenses/', (req, res, ctx) => {
const newExpense = {
id: mockExpenses.length + 1,
user_id: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...(req.body as any),
};
mockExpenses.push(newExpense);
return res(ctx.status(201), ctx.json({ data: newExpense }));
}),
rest.put('/api/expenses/:id', (req, res, ctx) => {
const { id } = req.params;
const index = mockExpenses.findIndex(exp => exp.id === parseInt(id as string));
if (index === -1) {
return res(ctx.status(404), ctx.json({ detail: 'Expense not found' }));
}
mockExpenses[index] = {
...mockExpenses[index],
...(req.body as any),
updated_at: new Date().toISOString(),
};
return res(ctx.json({ data: mockExpenses[index] }));
}),
rest.delete('/api/expenses/:id', (req, res, ctx) => {
const { id } = req.params;
const index = mockExpenses.findIndex(exp => exp.id === parseInt(id as string));
if (index === -1) {
return res(ctx.status(404), ctx.json({ detail: 'Expense not found' }));
}
mockExpenses.splice(index, 1);
return res(ctx.status(204));
}),
// Budget endpoints
rest.get('/api/budgets/summary', (req, res, ctx) => {
const month = req.url.searchParams.get('month');
const year = req.url.searchParams.get('year');
const mockBudgetSummary = {
month: parseInt(month || '3'),
year: parseInt(year || '2024'),
total_budget: 1000.0,
total_spent: 450.0,
remaining: 550.0,
is_over_budget: false,
categories: [
{
category_id: 1,
category_name: 'Food & Dining',
budget_amount: 500.0,
spent_amount: 250.0,
remaining: 250.0,
percentage_used: 50.0,
is_over_budget: false,
},
],
};
return res(ctx.json({ data: mockBudgetSummary }));
}),
// Error scenarios
rest.post('/api/expenses/error', (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
detail: 'Expense exceeds monthly budget limit. Remaining: $50.00',
error_type: 'budget_exceeded',
remaining_budget: 50.0,
})
);
}),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());// src/components/__tests__/ExpenseForm.integration.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ExpenseForm } from '../expenses/ExpenseForm';
import { server } from '@/test/setup';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('ExpenseForm Integration', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it('creates expense with all fields', async () => {
const user = userEvent.setup();
render(
<ExpenseForm onSubmit={mockOnSubmit} />,
{ wrapper: createWrapper() }
);
// Wait for categories to load
await waitFor(() => {
expect(screen.getByText('Food & Dining')).toBeInTheDocument();
});
// Fill out form
await user.type(screen.getByPlaceholderText('0.00'), '99.99');
await user.selectOptions(screen.getByRole('combobox'), ['1']);
await user.type(screen.getByPlaceholderText('Describe the expense...'), 'Business lunch');
await user.type(screen.getByPlaceholderText('Store/Restaurant name'), 'Fine Dining');
await user.type(screen.getByPlaceholderText('Receipt number'), 'R001');
// Submit form
await user.click(screen.getByText('Create Expense'));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
amount: 99.99,
description: 'Business lunch',
merchant_name: 'Fine Dining',
receipt_number: 'R001',
category_id: 1,
expense_date: expect.any(String),
});
});
});
it('shows validation errors for invalid input', async () => {
const user = userEvent.setup();
render(
<ExpenseForm onSubmit={mockOnSubmit} />,
{ wrapper: createWrapper() }
);
// Try to submit without required fields
await user.click(screen.getByText('Create Expense'));
await waitFor(() => {
expect(screen.getByText('Amount must be greater than 0')).toBeInTheDocument();
expect(screen.getByText('Description is required')).toBeInTheDocument();
expect(screen.getByText('Category is required')).toBeInTheDocument();
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('handles server validation errors', async () => {
// Mock server error response
server.use(
rest.post('/api/expenses/', (req, res, ctx) => {
return res(
ctx.status(422),
ctx.json({
detail: [
{
loc: ['body', 'amount'],
msg: 'Amount must be positive',
type: 'value_error',
},
],
})
);
})
);
const user = userEvent.setup();
render(
<ExpenseForm onSubmit={mockOnSubmit} />,
{ wrapper: createWrapper() }
);
// Fill and submit form
await user.type(screen.getByPlaceholderText('0.00'), '-10');
await user.type(screen.getByPlaceholderText('Describe the expense...'), 'Invalid expense');
await user.click(screen.getByText('Create Expense'));
// Should show server validation error
await waitFor(() => {
expect(screen.getByText(/Validation Error/)).toBeInTheDocument();
});
});
});// tests/e2e/expense-management.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Expense Management Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Mock authentication if needed
});
test('complete expense creation workflow', async ({ page }) => {
// Navigate to expenses page
await page.click('[data-testid="expenses-nav"]');
// Open create expense dialog
await page.click('[data-testid="create-expense-btn"]');
// Fill out expense form
await page.fill('[data-testid="amount-input"]', '125.50');
await page.selectOption('[data-testid="category-select"]', '1');
await page.fill('[data-testid="description-input"]', 'Team lunch meeting');
await page.fill('[data-testid="merchant-input"]', 'Downtown Bistro');
// Submit form
await page.click('[data-testid="submit-expense-btn"]');
// Verify success notification
await expect(page.locator('[data-testid="toast-success"]')).toBeVisible();
// Verify expense appears in list
await expect(page.locator('[data-testid="expense-list"]')).toContainText('Team lunch meeting');
await expect(page.locator('[data-testid="expense-list"]')).toContainText('$125.50');
});
test('budget warning workflow', async ({ page }) => {
// Set a low budget
await page.click('[data-testid="budgets-nav"]');
await page.click('[data-testid="create-budget-btn"]');
await page.fill('[data-testid="budget-amount"]', '100');
await page.selectOption('[data-testid="budget-category"]', '1');
await page.click('[data-testid="submit-budget-btn"]');
// Create expense that approaches budget limit
await page.click('[data-testid="expenses-nav"]');
await page.click('[data-testid="create-expense-btn"]');
await page.fill('[data-testid="amount-input"]', '85');
await page.selectOption('[data-testid="category-select"]', '1');
await page.fill('[data-testid="description-input"]', 'Large expense');
await page.click('[data-testid="submit-expense-btn"]');
// Should show budget warning
await expect(page.locator('[data-testid="toast-warning"]')).toBeVisible();
await expect(page.locator('[data-testid="toast-warning"]')).toContainText('close to your budget limit');
});
test('expense filtering and search', async ({ page }) => {
await page.click('[data-testid="expenses-nav"]');
// Open filters
await page.click('[data-testid="filters-toggle"]');
// Filter by category
await page.selectOption('[data-testid="category-filter"]', '1');
// Search by description
await page.fill('[data-testid="search-input"]', 'lunch');
// Verify filtered results
const expenseCards = page.locator('[data-testid="expense-card"]');
await expect(expenseCards).toHaveCount(1);
await expect(expenseCards.first()).toContainText('lunch');
});
test('responsive design on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.click('[data-testid="expenses-nav"]');
// Mobile menu should be visible
await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible();
// Cards should stack vertically
const expenseCards = page.locator('[data-testid="expense-card"]');
const firstCard = expenseCards.first();
const secondCard = expenseCards.nth(1);
if (await expenseCards.count() > 1) {
const firstBox = await firstCard.boundingBox();
const secondBox = await secondCard.boundingBox();
// Second card should be below first card (stacked)
expect(secondBox?.y).toBeGreaterThan(firstBox?.y! + firstBox?.height!);
}
});
});// src/App.tsx - Final Complete Application
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router-dom';
import { AppProvider } from '@/context/AppContext';
import { ThemeProvider } from '@/context/ThemeContext';
import { AppRouter } from '@/router/AppRouter';
import { Toaster } from '@/components/ui/toaster';
import { SkipLink } from '@/components/accessible/SkipLink';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { config } from '@/config/environment';
import { registerServiceWorker } from '@/utils/serviceWorker';
import './globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: (failureCount, error: any) => {
if (error?.response?.status === 404) return false;
return failureCount < 3;
},
},
mutations: {
retry: 1,
},
},
});
// Register service worker for PWA capabilities
if (process.env.NODE_ENV === 'production') {
registerServiceWorker();
}
function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppProvider>
<ThemeProvider>
<SkipLink />
<AppRouter />
<Toaster />
{config.enableDebug && <ReactQueryDevtools />}
</ThemeProvider>
</AppProvider>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
);
}
export default App;// src/router/AppRouter.tsx - Enhanced with all routes
import React, { Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from '@/components/layout/Layout';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
// Lazy load all pages
const Dashboard = React.lazy(() => import('@/pages/Dashboard'));
const ExpensesPage = React.lazy(() => import('@/pages/ExpensesPage'));
const BudgetsPage = React.lazy(() => import('@/pages/BudgetsPage'));
const AnalyticsPage = React.lazy(() => import('@/pages/AnalyticsPage'));
const CategoriesPage = React.lazy(() => import('@/pages/CategoriesPage'));
const SettingsPage = React.lazy(() => import('@/pages/SettingsPage'));
const NotFoundPage = React.lazy(() => import('@/pages/NotFoundPage'));
export function AppRouter() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
} />
<Route path="expenses" element={
<Suspense fallback={<LoadingSpinner />}>
<ExpensesPage />
</Suspense>
} />
<Route path="budgets" element={
<Suspense fallback={<LoadingSpinner />}>
<BudgetsPage />dContent>
</Card>
)}
</div>
);
}// src/components/dashboard/BudgetProgress.tsx
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { AlertTriangle, CheckCircle } from 'lucide-react';
import { BudgetSummary } from '@/types/expense';
interface BudgetProgressProps {
budgetSummary?: BudgetSummary;
}
export function BudgetProgress({ budgetSummary }: BudgetProgressProps) {
if (!budgetSummary || budgetSummary.categories.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Budget Progress</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-center py-4">
No budgets set for this period
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Budget Progress
{budgetSummary.is_over_budget ? (
<AlertTriangle className="h-5 w-5 text-red-500" />
) : (
<CheckCircle className="h-5 w-5 text-green-500" />
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Progress */}
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Overall Budget</span>
<span className="text-sm text-gray-600">
${budgetSummary.total_spent.toFixed(2)} / ${budgetSummary.total_budget.toFixed(2)}
</span>
</div>
<Progress
value={Math.min((budgetSummary.total_spent / budgetSummary.total_budget) * 100, 100)}
className="h-2"
/>
<div className="mt-1 text-xs text-gray-600">
{budgetSummary.remaining >= 0
? `${budgetSummary.remaining.toFixed(2)} remaining`
: `${Math.abs(budgetSummary.remaining).toFixed(2)} over budget`
}
</div>
</div>
{/* Category Progress */}
<div className="space-y-3">
{budgetSummary.categories.map((category) => (
<div key={category.category_id} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{category.category_name}</span>
<span className="text-xs text-gray-600">
${category.spent_amount.toFixed(2)} / ${category.budget_amount.toFixed(2)}
</span>
</div>
<Progress
value={Math.min(category.percentage_used, 100)}
className={`h-2 ${category.is_over_budget ? 'bg-red-100' : ''}`}
/>
<div className="flex justify-between items-center text-xs">
<span className={category.is_over_budget ? 'text-red-600' : 'text-gray-600'}>
{category.percentage_used.toFixed(1)}% used
</span>
<span className={category.remaining >= 0 ? 'text-green-600' : 'text-red-600'}>
{category.remaining >= 0
? `${category.remaining.toFixed(2)} left`
: `${Math.abs(category.remaining).toFixed(2)} over`
}
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}// src/components/budgets/BudgetManager.tsx
import React, { useState } from 'react';
import { Plus, Edit, Trash2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { BudgetForm } from './BudgetForm';
import { useBudgetSummary, useCreateBudget } from '@/hooks/useBudgets';
import { useCategories } from '@/hooks/useCategories';
import { BudgetCreate } from '@/types/expense';
export function BudgetManager() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const currentDate = new Date();
const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth() + 1);
const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear());
const { data: budgetSummary, isLoading } = useBudgetSummary(selectedMonth, selectedYear);
const { data: categories } = useCategories();
const createBudgetMutation = useCreateBudget();
const handleCreateBudget = async (data: BudgetCreate) => {
try {
await createBudgetMutation.mutateAsync(data);
setIsDialogOpen(false);
} catch (error) {
// Error handled by the hook
}
};
const availableCategories = categories?.filter(category =>
!budgetSummary?.categories.some(budget => budget.category_id === category.id)
) || [];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Budget Management</h1>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button disabled={availableCategories.length === 0}>
<Plus className="h-4 w-4 mr-2" />
Add Budget
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Budget</DialogTitle>
</DialogHeader>
<BudgetForm
availableCategories={availableCategories}
defaultMonth={selectedMonth}
defaultYear={selectedYear}
onSubmit={handleCreateBudget}
isLoading={createBudgetMutation.isPending}
/>
</DialogContent>
</Dialog>
</div>
{/* Period Selector */}
<Card>
<CardHeader>
<CardTitle>Budget Period</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(parseInt(e.target.value))}
className="border rounded px-3 py-2"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2024, i).toLocaleString('default', { month: 'long' })}
</option>
))}
</select>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="border rounded px-3 py-2"
>
{Array.from({ length: 5 }, (_, i) => (
<option key={currentDate.getFullYear() - 2 + i} value={currentDate.getFullYear() - 2 + i}>
{currentDate.getFullYear() - 2 + i}
</option>
))}
</select>
</div>
</CardContent>
</Card>
{/* Budget Summary */}
{budgetSummary && (
<Card>
<CardHeader>
<CardTitle>
Budget Summary - {new Date(selectedYear, selectedMonth - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-blue-50 rounded">
<div className="text-2xl font-bold text-blue-600">
${budgetSummary.total_budget.toFixed(2)}
</div>
<div className="text-sm text-gray-600">Total Budget</div>
</div>
<div className="text-center p-4 bg-green-50 rounded">
<div className="text-2xl font-bold text-green-600">
${budgetSummary.total_spent.toFixed(2)}
</div>
<div className="text-sm text-gray-600">Total Spent</div>
</div>
<div className={`text-center p-4 rounded ${
budgetSummary.remaining >= 0 ? 'bg-green-50' : 'bg-red-50'
}`}>
<div className={`text-2xl font-bold ${
budgetSummary.remaining >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
${Math.abs(budgetSummary.remaining).toFixed(2)}
</div>
<div className="text-sm text-gray-600">
{budgetSummary.remaining >= 0 ? 'Remaining' : 'Over Budget'}
</div>
</div>
</div>
{/* Category Budgets */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Category Budgets</h3>
{budgetSummary.categories.map((category) => (
<Card key={category.category_id} className="p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-medium">{category.category_name}</h4>
<div className="mt-2 space-y-1">
<div className="flex justify-between text-sm">
<span>Budget: ${category.budget_amount.toFixed(2)}</span>
<span>Spent: ${category.spent_amount.toFixed(2)}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
category.is_over_budget ? 'bg-red-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(category.percentage_used, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{category.percentage_used.toFixed(1)}% used</span>
<span className={category.remaining >= 0 ? 'text-green-600' : 'text-red-600'}>
{category.remaining >= 0
? `${category.remaining.toFixed(2)} left`
: `${Math.abs(category.remaining).toFixed(2)} over`
}
</span>
</div>
</div>
</div>
<div className="flex gap-2 ml-4">
<Button size="sm" variant="outline">
<Edit className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline">
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</Card>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}// src/components/analytics/ExpenseAnalytics.tsx
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePickerWithRange } from '@/components/ui/date-range-picker';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { useExpenses } from '@/hooks/useExpenses';
import { useCategories } from '@/hooks/useCategories';
import { format, startOfMonth, endOfMonth, eachMonthOfInterval, subMonths } from 'date-fns';
import { DateRange } from 'react-day-picker';
export function ExpenseAnalytics() {
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subMonths(new Date(), 5),
to: new Date(),
});
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [viewType, setViewType] = useState<'monthly' | 'category' | 'trends'>('monthly');
const { data: categories } = useCategories();
const { data: expenses } = useExpenses({
start_date: dateRange?.from ? format(dateRange.from, 'yyyy-MM-dd') : undefined,
end_date: dateRange?.to ? format(dateRange.to, 'yyyy-MM-dd') : undefined,
category_id: selectedCategory !== 'all' ? parseInt(selectedCategory) : undefined,
});
// Process data for monthly trends
const monthlyData = React.useMemo(() => {
if (!expenses || !dateRange?.from || !dateRange?.to) return [];
const months = eachMonthOfInterval({
start: startOfMonth(dateRange.from),
end: endOfMonth(dateRange.to),
});
return months.map(month => {
const monthExpenses = expenses.filter(expense => {
const expenseDate = new Date(expense.expense_date);
return expenseDate.getMonth() === month.getMonth() &&
expenseDate.getFullYear() === month.getFullYear();
});
const total = monthExpenses.reduce((sum, expense) => sum + expense.amount, 0);
const count = monthExpenses.length;
return {
month: format(month, 'MMM yyyy'),
total,
count,
average: count > 0 ? total / count : 0,
};
});
}, [expenses, dateRange]);
// Process data for category breakdown
const categoryData = React.useMemo(() => {
if (!expenses || !categories) return [];
const categoryTotals = expenses.reduce((acc, expense) => {
const category = categories.find(c => c.id === expense.category_id);
const categoryName = category?.name || 'Unknown';
if (!acc[categoryName]) {
acc[categoryName] = { total: 0, count: 0 };
}
acc[categoryName].total += expense.amount;
acc[categoryName].count += 1;
return acc;
}, {} as Record<string, { total: number; count: number }>);
return Object.entries(categoryTotals).map(([name, data]) => ({
category: name,
total: data.total,
count: data.count,
average: data.total / data.count,
}));
}, [expenses, categories]);
const exportToCSV = () => {
if (!expenses) return;
const csvContent = [
['Date', 'Amount', 'Description', 'Category', 'Merchant', 'Receipt'],
...expenses.map(expense => [
format(new Date(expense.expense_date), 'yyyy-MM-dd'),
expense.amount.toString(),
expense.description,
categories?.find(c => c.id === expense.category_id)?.name || '',
expense.merchant_name || '',
expense.receipt_number || '',
])
].map(row => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expenses-${format(new Date(), 'yyyy-MM-dd')}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Expense Analytics</h1>
<Button onClick={exportToCSV} disabled={!expenses?.length}>
Export CSV
</Button>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium">Date Range</label>
<DatePickerWithRange
date={dateRange}
onDateChange={setDateRange}
/>
</div>
<div className="min-w-[150px]">
<label className="text-sm font-medium">Category</label>
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories?.map(category => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-[150px]">
<label className="text-sm font-medium">View Type</label>
<Select value={viewType} onValueChange={(value: any) => setViewType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly Trends</SelectItem>
<SelectItem value="category">Category Breakdown</SelectItem>
<SelectItem value="trends">Spending Trends</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Charts */}
{viewType === 'monthly' && (
<Card>
<CardHeader>
<CardTitle>Monthly Spending Trends</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(value) => [`${Number(value).toFixed(2)}`, 'Amount']} />
<Line
type="monotone"
dataKey="total"
stroke="#8884d8"
strokeWidth={2}
name="Total Spent"
/>
<Line
type="monotone"
dataKey="count"
stroke="#82ca9d"
strokeWidth={2}
name="Number of Expenses"
yAxisId="right"
/>
</LineChart>
</ResponsiveContainer>
</div>
</Car