Add lock files for package management and update architecture documentation

- Introduced bun.lock and package-lock.json to manage dependencies for the project.
- Enhanced backend API architecture documentation with additional security and documentation guidelines.
- Made minor formatting adjustments across various files for consistency and clarity.
This commit is contained in:
2025-12-11 02:11:43 -05:00
parent 4911b5d125
commit 40210c454e
74 changed files with 2599 additions and 1386 deletions

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

View File

@@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Environment variables
.env
.env.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -1,6 +1,9 @@
import {lazy, Suspense} from 'react';
import {lazy, Suspense, useEffect} from 'react';
import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom';
import {useAppSelector} from '@/store';
import {useAppSelector, useAppDispatch} from '@/store';
import {loadUserFromStorage} from '@/store/slices/userSlice';
import {fetchAssets, fetchLiabilities, fetchSnapshots} from '@/store/slices/netWorthSlice';
import {fetchIncomeSources, fetchExpenses, fetchTransactions} from '@/store/slices/cashflowSlice';
import Layout from '@/components/Layout';
import ProtectedRoute from '@/components/ProtectedRoute';
@@ -20,7 +23,30 @@ const PageLoader = () => (
);
function AppRoutes() {
const {isAuthenticated} = useAppSelector(state => state.user);
const dispatch = useAppDispatch();
const {isAuthenticated, isLoading} = useAppSelector(state => state.user);
// Load user from storage on app start
useEffect(() => {
dispatch(loadUserFromStorage());
}, [dispatch]);
// Fetch all data when user is authenticated
useEffect(() => {
if (isAuthenticated) {
dispatch(fetchAssets());
dispatch(fetchLiabilities());
dispatch(fetchSnapshots());
dispatch(fetchIncomeSources());
dispatch(fetchExpenses());
dispatch(fetchTransactions());
}
}, [isAuthenticated, dispatch]);
// Show loading while checking authentication
if (isLoading) {
return <PageLoader />;
}
return (
<Routes>

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, addAsset} from '@/store';
import {useAppDispatch, createAsset} from '@/store';
import {validatePositiveNumber, validateRequired, sanitizeString} from '@/lib/validation';
interface Props {
@@ -46,12 +46,10 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
if (valueNum === null) return;
dispatch(
addAsset({
id: crypto.randomUUID(),
createAsset({
name: sanitizeString(form.name),
type: form.type as (typeof assetTypes)[number],
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
value: valueNum,
updatedAt: new Date().toISOString()
})
);
onOpenChange(false);

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, addLiability} from '@/store';
import {useAppDispatch, createLiability} from '@/store';
interface Props {
open: boolean;
@@ -20,12 +20,10 @@ export default function AddLiabilityDialog({open, onOpenChange}: Props) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch(
addLiability({
id: crypto.randomUUID(),
createLiability({
name: form.name,
type: form.type as (typeof liabilityTypes)[number],
balance: parseFloat(form.balance) || 0,
updatedAt: new Date().toISOString()
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
currentBalance: parseFloat(form.balance) || 0,
})
);
onOpenChange(false);

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, updateAsset, removeAsset, type Asset} from '@/store';
import {useAppDispatch, updateAsset, deleteAsset, type Asset} from '@/store';
import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props {
@@ -60,10 +60,11 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
dispatch(
updateAsset({
id: asset.id,
name: form.name.trim(),
type: form.type as (typeof assetTypes)[number],
value: valueNum,
updatedAt: new Date().toISOString()
data: {
name: form.name.trim(),
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
value: valueNum,
}
})
);
onOpenChange(false);
@@ -72,7 +73,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
const handleDelete = () => {
if (!asset) return;
if (confirm(`Are you sure you want to delete "${asset.name}"?`)) {
dispatch(removeAsset(asset.id));
dispatch(deleteAsset(asset.id));
onOpenChange(false);
}
};

View File

@@ -4,7 +4,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, updateLiability, removeLiability, type Liability} from '@/store';
import {useAppDispatch, updateLiability, deleteLiability, type Liability} from '@/store';
import {validatePositiveNumber, validateRequired} from '@/lib/validation';
interface Props {
@@ -60,10 +60,11 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
dispatch(
updateLiability({
id: liability.id,
name: form.name.trim(),
type: form.type as (typeof liabilityTypes)[number],
balance: balanceNum,
updatedAt: new Date().toISOString()
data: {
name: form.name.trim(),
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
currentBalance: balanceNum,
}
})
);
onOpenChange(false);
@@ -72,7 +73,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
const handleDelete = () => {
if (!liability) return;
if (confirm(`Are you sure you want to delete "${liability.name}"?`)) {
dispatch(removeLiability(liability.id));
dispatch(deleteLiability(liability.id));
onOpenChange(false);
}
};

View File

@@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store';
import {useAppDispatch} from '@/store';
import {loginUser} from '@/store/slices/userSlice';
interface Props {
open: boolean;
@@ -18,26 +19,33 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
password: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Mock login - in production this would validate against an API
if (!form.email || !form.password) {
setError('Please enter your email and password');
return;
}
// Mock successful login
dispatch(setUser({
id: crypto.randomUUID(),
email: form.email,
name: form.email.split('@')[0],
}));
setIsLoading(true);
try {
await dispatch(
loginUser({
email: form.email,
password: form.password,
})
).unwrap();
onOpenChange(false);
setForm({email: '', password: ''});
onOpenChange(false);
setForm({email: '', password: ''});
} catch (err: any) {
setError(err || 'Login failed. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
@@ -76,8 +84,10 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Log in</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp}>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log in'}
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp} disabled={isLoading}>
Don't have an account? Sign up
</Button>
</DialogFooter>

View File

@@ -3,7 +3,8 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store';
import {useAppDispatch} from '@/store';
import {registerUser} from '@/store/slices/userSlice';
interface Props {
open: boolean;
@@ -20,8 +21,9 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
confirmPassword: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -30,20 +32,28 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
return;
}
if (form.password.length < 6) {
setError('Password must be at least 6 characters');
if (form.password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
// Mock sign up - in production this would call an API
dispatch(setUser({
id: crypto.randomUUID(),
email: form.email,
name: form.name,
}));
setIsLoading(true);
try {
await dispatch(
registerUser({
email: form.email,
password: form.password,
name: form.name,
})
).unwrap();
onOpenChange(false);
setForm({name: '', email: '', password: '', confirmPassword: ''});
onOpenChange(false);
setForm({name: '', email: '', password: '', confirmPassword: ''});
} catch (err: any) {
setError(err || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
@@ -109,8 +119,10 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
</p>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Create account</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin}>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin} disabled={isLoading}>
Already have an account? Log in
</Button>
</DialogFooter>

View File

@@ -0,0 +1,71 @@
/**
* Authentication Service
*/
import {apiClient} from './client';
import {tokenStorage} from './token';
export interface RegisterRequest {
email: string;
password: string;
name: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface AuthResponse {
token: string;
user: {
id: string;
email: string;
name: string;
};
}
export interface UserProfile {
id: string;
email: string;
name: string;
createdAt: string;
}
export const authService = {
async register(data: RegisterRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/register', data);
tokenStorage.setToken(response.token);
tokenStorage.setUser(JSON.stringify(response.user));
return response;
},
async login(data: LoginRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/login', data);
tokenStorage.setToken(response.token);
tokenStorage.setUser(JSON.stringify(response.user));
return response;
},
async getProfile(): Promise<UserProfile> {
return apiClient.get<UserProfile>('/profile');
},
logout(): void {
tokenStorage.clear();
},
isAuthenticated(): boolean {
return !!tokenStorage.getToken();
},
getCurrentUser(): {id: string; email: string; name: string} | null {
const userStr = tokenStorage.getUser();
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
},
};

View File

@@ -0,0 +1,88 @@
/**
* Cashflow Service
*/
import {apiClient} from './client';
export interface IncomeSource {
id: string;
name: string;
amount: number;
frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE';
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Expense {
id: string;
name: string;
amount: number;
frequency: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY' | 'ONCE';
category?: string;
isEssential?: boolean;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Transaction {
id: string;
type: 'INCOME' | 'EXPENSE';
amount: number;
description: string;
category?: string;
date: string;
notes?: string;
createdAt?: string;
}
export const incomeService = {
async getAll(): Promise<{incomeSources: IncomeSource[]}> {
return apiClient.get<{incomeSources: IncomeSource[]}>('/cashflow/income');
},
async create(data: Partial<IncomeSource>): Promise<{incomeSource: IncomeSource}> {
return apiClient.post<{incomeSource: IncomeSource}>('/cashflow/income', data);
},
async update(id: string, data: Partial<IncomeSource>): Promise<{incomeSource: IncomeSource}> {
return apiClient.put<{incomeSource: IncomeSource}>(`/cashflow/income/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/income/${id}`);
},
};
export const expenseService = {
async getAll(): Promise<{expenses: Expense[]}> {
return apiClient.get<{expenses: Expense[]}>('/cashflow/expenses');
},
async create(data: Partial<Expense>): Promise<{expense: Expense}> {
return apiClient.post<{expense: Expense}>('/cashflow/expenses', data);
},
async update(id: string, data: Partial<Expense>): Promise<{expense: Expense}> {
return apiClient.put<{expense: Expense}>(`/cashflow/expenses/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/expenses/${id}`);
},
};
export const transactionService = {
async getAll(): Promise<{transactions: Transaction[]}> {
return apiClient.get<{transactions: Transaction[]}>('/cashflow/transactions');
},
async create(data: Partial<Transaction>): Promise<{transaction: Transaction}> {
return apiClient.post<{transaction: Transaction}>('/cashflow/transactions', data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/cashflow/transactions/${id}`);
},
};

View File

@@ -0,0 +1,110 @@
/**
* API Client
* Base configuration for all API requests
*/
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const token = this.getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle non-JSON responses
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
if (!response.ok) {
throw {
message: 'Request failed',
statusCode: response.status,
error: response.statusText,
} as ApiError;
}
return {} as T;
}
const data = await response.json();
if (!response.ok) {
throw {
message: data.message || 'Request failed',
statusCode: response.status,
error: data.error,
} as ApiError;
}
return data;
} catch (error) {
if ((error as ApiError).statusCode) {
throw error;
}
throw {
message: 'Network error',
statusCode: 0,
error: String(error),
} as ApiError;
}
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {method: 'GET'});
}
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {method: 'DELETE'});
}
}
export const apiClient = new ApiClient(API_URL);

View File

@@ -0,0 +1,144 @@
/**
* Net Worth Service
*/
import {apiClient} from './client';
export interface Asset {
id: string;
name: string;
type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateAssetRequest {
name: string;
type: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value: number;
}
export interface UpdateAssetRequest {
name?: string;
type?: 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER';
value?: number;
}
export interface Liability {
id: string;
name: string;
type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateLiabilityRequest {
name: string;
type: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
}
export interface UpdateLiabilityRequest {
name?: string;
type?: 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER';
currentBalance?: number;
interestRate?: number;
minimumPayment?: number;
dueDate?: string;
creditor?: string;
notes?: string;
}
export interface NetWorthSnapshot {
id: string;
date: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
notes?: string;
createdAt?: string;
}
export const assetService = {
async getAll(): Promise<{assets: Asset[]}> {
return apiClient.get<{assets: Asset[]}>('/assets');
},
async getById(id: string): Promise<{asset: Asset}> {
return apiClient.get<{asset: Asset}>(`/assets/${id}`);
},
async create(data: CreateAssetRequest): Promise<{asset: Asset}> {
return apiClient.post<{asset: Asset}>('/assets', data);
},
async update(id: string, data: UpdateAssetRequest): Promise<{asset: Asset}> {
return apiClient.put<{asset: Asset}>(`/assets/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/assets/${id}`);
},
};
export const liabilityService = {
async getAll(): Promise<{liabilities: Liability[]}> {
return apiClient.get<{liabilities: Liability[]}>('/liabilities');
},
async getById(id: string): Promise<{liability: Liability}> {
return apiClient.get<{liability: Liability}>(`/liabilities/${id}`);
},
async create(data: CreateLiabilityRequest): Promise<{liability: Liability}> {
return apiClient.post<{liability: Liability}>('/liabilities', data);
},
async update(id: string, data: UpdateLiabilityRequest): Promise<{liability: Liability}> {
return apiClient.put<{liability: Liability}>(`/liabilities/${id}`, data);
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/liabilities/${id}`);
},
};
export const snapshotService = {
async getAll(): Promise<{snapshots: NetWorthSnapshot[]}> {
return apiClient.get<{snapshots: NetWorthSnapshot[]}>('/networth/snapshots');
},
async getById(id: string): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.get<{snapshot: NetWorthSnapshot}>(`/networth/snapshots/${id}`);
},
async create(data: {
date: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
notes?: string;
}): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots', data);
},
async createFromCurrent(notes?: string): Promise<{snapshot: NetWorthSnapshot}> {
return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots/record', {notes});
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/networth/snapshots/${id}`);
},
};

View File

@@ -0,0 +1,37 @@
/**
* Token Storage Utilities
*/
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const tokenStorage = {
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
},
getUser(): string | null {
return localStorage.getItem(USER_KEY);
},
setUser(user: string): void {
localStorage.setItem(USER_KEY, user);
},
removeUser(): void {
localStorage.removeItem(USER_KEY);
},
clear(): void {
this.removeToken();
this.removeUser();
},
};

View File

@@ -1,7 +1,7 @@
import {useState} from 'react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector, useAppDispatch, addSnapshot, type Asset, type Liability} from '@/store';
import {useAppSelector, type Asset, type Liability} from '@/store';
import {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts';
import {format} from 'date-fns';
import AddAssetDialog from '@/components/dialogs/AddAssetDialog';
@@ -12,7 +12,6 @@ import {formatCurrency, formatPercentage} from '@/lib/formatters';
import {calculateMonthlyChange, calculateYTDGrowth} from '@/lib/calculations';
export default function NetWorthPage() {
const dispatch = useAppDispatch();
const [assetDialogOpen, setAssetDialogOpen] = useState(false);
const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false);
const [editAssetDialogOpen, setEditAssetDialogOpen] = useState(false);
@@ -34,14 +33,8 @@ export default function NetWorthPage() {
const ytdGrowth = calculateYTDGrowth(snapshots);
const handleRecordSnapshot = () => {
const snapshot = {
id: crypto.randomUUID(),
date: new Date().toISOString().split('T')[0],
totalAssets,
totalLiabilities,
netWorth
};
dispatch(addSnapshot(snapshot));
// TODO: Implement createSnapshot thunk
console.log('Record snapshot functionality not yet implemented');
};
const handleEditAsset = (asset: Asset) => {

View File

@@ -13,14 +13,15 @@ export type {User, UserState} from './slices/userSlice';
export {
setLoading as setNetWorthLoading,
setError as setNetWorthError,
addAsset,
fetchAssets,
createAsset,
updateAsset,
removeAsset,
addLiability,
deleteAsset,
fetchLiabilities,
createLiability,
updateLiability,
removeLiability,
addSnapshot,
setSnapshots
deleteLiability,
fetchSnapshots
} from './slices/netWorthSlice';
export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/netWorthSlice';

View File

@@ -1,4 +1,5 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {incomeService, expenseService, transactionService, type IncomeSource as ApiIncome, type Expense as ApiExpense, type Transaction as ApiTransaction} from '@/lib/api/cashflow.service';
export interface IncomeSource {
id: string;
@@ -47,162 +48,77 @@ const defaultCategories = {
expense: ['Housing', 'Utilities', 'Transportation', 'Food', 'Insurance', 'Healthcare', 'Subscriptions', 'Entertainment', 'Shopping', 'Savings', 'Other']
};
// Mock data
const mockIncomeSources: IncomeSource[] = [
{
id: 'i1',
name: 'Software Engineer Salary',
amount: 8500,
frequency: 'monthly',
category: 'Salary',
nextDate: '2024-12-15',
isActive: true,
createdAt: '2024-01-01'
},
{id: 'i2', name: 'Consulting', amount: 2000, frequency: 'monthly', category: 'Freelance', nextDate: '2024-12-20', isActive: true, createdAt: '2024-03-01'},
{
id: 'i3',
name: 'Dividend Income',
amount: 450,
frequency: 'quarterly',
category: 'Investments',
nextDate: '2024-12-31',
isActive: true,
createdAt: '2024-01-01'
}
];
const mockExpenses: Expense[] = [
{
id: 'e1',
name: 'Mortgage',
amount: 2200,
frequency: 'monthly',
category: 'Housing',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e2',
name: 'Car Payment',
amount: 450,
frequency: 'monthly',
category: 'Transportation',
nextDate: '2024-12-05',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e3',
name: 'Car Insurance',
amount: 180,
frequency: 'monthly',
category: 'Insurance',
nextDate: '2024-12-10',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e4',
name: 'Utilities',
amount: 250,
frequency: 'monthly',
category: 'Utilities',
nextDate: '2024-12-15',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e5',
name: 'Groceries',
amount: 600,
frequency: 'monthly',
category: 'Food',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e6',
name: 'Gym Membership',
amount: 50,
frequency: 'monthly',
category: 'Healthcare',
nextDate: '2024-12-01',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e7',
name: 'Netflix',
amount: 15,
frequency: 'monthly',
category: 'Subscriptions',
nextDate: '2024-12-08',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e8',
name: 'Spotify',
amount: 12,
frequency: 'monthly',
category: 'Subscriptions',
nextDate: '2024-12-12',
isActive: true,
isEssential: false,
createdAt: '2024-01-01'
},
{
id: 'e9',
name: 'Health Insurance',
amount: 350,
frequency: 'monthly',
category: 'Insurance',
nextDate: '2024-12-01',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
},
{
id: 'e10',
name: '401k Contribution',
amount: 1500,
frequency: 'monthly',
category: 'Savings',
nextDate: '2024-12-15',
isActive: true,
isEssential: true,
createdAt: '2024-01-01'
}
];
const mockTransactions: Transaction[] = [
{id: 't1', type: 'income', name: 'Salary', amount: 8500, category: 'Salary', date: '2024-11-15'},
{id: 't2', type: 'expense', name: 'Mortgage', amount: 2200, category: 'Housing', date: '2024-11-01'},
{id: 't3', type: 'expense', name: 'Groceries', amount: 145, category: 'Food', date: '2024-11-28'},
{id: 't4', type: 'expense', name: 'Gas', amount: 55, category: 'Transportation', date: '2024-11-25'},
{id: 't5', type: 'income', name: 'Consulting Payment', amount: 2000, category: 'Freelance', date: '2024-11-20'},
{id: 't6', type: 'expense', name: 'Restaurant', amount: 85, category: 'Food', date: '2024-11-22'}
];
const initialState: CashflowState = {
incomeSources: mockIncomeSources,
expenses: mockExpenses,
transactions: mockTransactions,
incomeSources: [],
expenses: [],
transactions: [],
categories: defaultCategories,
isLoading: false,
error: null
};
// Helper mappers
const mapApiIncomeToIncome = (apiIncome: ApiIncome): IncomeSource => ({
id: apiIncome.id,
name: apiIncome.name,
amount: apiIncome.amount,
frequency: apiIncome.frequency.toLowerCase() as IncomeSource['frequency'],
category: 'Income',
nextDate: new Date().toISOString(),
isActive: true,
createdAt: apiIncome.createdAt || new Date().toISOString(),
});
const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({
id: apiExpense.id,
name: apiExpense.name,
amount: apiExpense.amount,
frequency: apiExpense.frequency.toLowerCase() as Expense['frequency'],
category: apiExpense.category || 'Other',
nextDate: new Date().toISOString(),
isActive: true,
isEssential: apiExpense.isEssential || false,
createdAt: apiExpense.createdAt || new Date().toISOString(),
});
const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transaction => ({
id: apiTransaction.id,
type: apiTransaction.type.toLowerCase() as Transaction['type'],
name: apiTransaction.description,
amount: apiTransaction.amount,
category: apiTransaction.category || 'Other',
date: apiTransaction.date,
note: apiTransaction.notes,
});
// Async thunks
export const fetchIncomeSources = createAsyncThunk('cashflow/fetchIncomeSources', async (_, {rejectWithValue}) => {
try {
const response = await incomeService.getAll();
return response.incomeSources.map(mapApiIncomeToIncome);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch income sources');
}
});
export const fetchExpenses = createAsyncThunk('cashflow/fetchExpenses', async (_, {rejectWithValue}) => {
try {
const response = await expenseService.getAll();
return response.expenses.map(mapApiExpenseToExpense);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch expenses');
}
});
export const fetchTransactions = createAsyncThunk('cashflow/fetchTransactions', async (_, {rejectWithValue}) => {
try {
const response = await transactionService.getAll();
return response.transactions.map(mapApiTransactionToTransaction);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch transactions');
}
});
const cashflowSlice = createSlice({
name: 'cashflow',
initialState,
@@ -242,7 +158,50 @@ const cashflowSlice = createSlice({
removeTransaction: (state, action: PayloadAction<string>) => {
state.transactions = state.transactions.filter(t => t.id !== action.payload);
}
}
},
extraReducers: builder => {
// Fetch income sources
builder.addCase(fetchIncomeSources.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchIncomeSources.fulfilled, (state, action) => {
state.isLoading = false;
state.incomeSources = action.payload;
});
builder.addCase(fetchIncomeSources.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch expenses
builder.addCase(fetchExpenses.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchExpenses.fulfilled, (state, action) => {
state.isLoading = false;
state.expenses = action.payload;
});
builder.addCase(fetchExpenses.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch transactions
builder.addCase(fetchTransactions.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchTransactions.fulfilled, (state, action) => {
state.isLoading = false;
state.transactions = action.payload;
});
builder.addCase(fetchTransactions.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const {

View File

@@ -50,104 +50,10 @@ const defaultCategories: DebtCategory[] = [
{id: 'other', name: 'Other', color: '#6b7280', createdAt: new Date().toISOString()}
];
// Mock data for development
const mockAccounts: DebtAccount[] = [
{
id: 'cc1',
name: 'Chase Sapphire Preferred',
categoryId: 'credit-cards',
institution: 'Chase',
accountNumber: '4521',
originalBalance: 8500,
currentBalance: 3200,
interestRate: 21.99,
minimumPayment: 95,
dueDay: 15,
createdAt: '2024-01-15',
updatedAt: '2024-12-01'
},
{
id: 'cc2',
name: 'Amex Blue Cash',
categoryId: 'credit-cards',
institution: 'American Express',
accountNumber: '1008',
originalBalance: 4200,
currentBalance: 1850,
interestRate: 19.24,
minimumPayment: 55,
dueDay: 22,
createdAt: '2024-02-10',
updatedAt: '2024-12-01'
},
{
id: 'cc3',
name: 'Citi Double Cash',
categoryId: 'credit-cards',
institution: 'Citibank',
accountNumber: '7732',
originalBalance: 2800,
currentBalance: 950,
interestRate: 18.49,
minimumPayment: 35,
dueDay: 8,
createdAt: '2024-03-05',
updatedAt: '2024-12-01'
},
{
id: 'al1',
name: 'Tesla Model 3 Loan',
categoryId: 'auto-loans',
institution: 'Tesla Finance',
accountNumber: '9901',
originalBalance: 42000,
currentBalance: 15000,
interestRate: 4.99,
minimumPayment: 650,
dueDay: 1,
createdAt: '2021-06-15',
updatedAt: '2024-12-01'
},
{
id: 'sl1',
name: 'Federal Student Loan',
categoryId: 'student-loans',
institution: 'Dept of Education',
originalBalance: 45000,
currentBalance: 28000,
interestRate: 5.5,
minimumPayment: 320,
dueDay: 25,
createdAt: '2018-09-01',
updatedAt: '2024-12-01'
},
{
id: 'pl1',
name: 'Home Improvement Loan',
categoryId: 'personal-loans',
institution: 'SoFi',
accountNumber: '3344',
originalBalance: 15000,
currentBalance: 8500,
interestRate: 8.99,
minimumPayment: 285,
dueDay: 12,
createdAt: '2023-08-20',
updatedAt: '2024-12-01'
}
];
const mockPayments: DebtPayment[] = [
{id: 'p1', accountId: 'cc1', amount: 500, date: '2024-11-15', note: 'Extra payment'},
{id: 'p2', accountId: 'cc2', amount: 200, date: '2024-11-22'},
{id: 'p3', accountId: 'al1', amount: 650, date: '2024-12-01'},
{id: 'p4', accountId: 'sl1', amount: 320, date: '2024-11-25'}
];
const initialState: DebtsState = {
categories: defaultCategories,
accounts: mockAccounts,
payments: mockPayments,
accounts: [],
payments: [],
isLoading: false,
error: null
};

View File

@@ -42,129 +42,9 @@ export interface InvoicesState {
error: string | null;
}
// Mock data for development
const mockClients: Client[] = [
{
id: 'c1',
name: 'Acme Corp',
email: 'billing@acme.com',
phone: '555-0100',
company: 'Acme Corporation',
address: '123 Business Ave, Suite 400, San Francisco, CA 94102',
createdAt: '2024-01-10'
},
{
id: 'c2',
name: 'TechStart Inc',
email: 'accounts@techstart.io',
phone: '555-0200',
company: 'TechStart Inc',
address: '456 Innovation Blvd, Austin, TX 78701',
createdAt: '2024-02-15'
},
{
id: 'c3',
name: 'Sarah Mitchell',
email: 'sarah@mitchell.design',
company: 'Mitchell Design Studio',
createdAt: '2024-03-22'
},
{
id: 'c4',
name: 'Global Media LLC',
email: 'finance@globalmedia.com',
phone: '555-0400',
company: 'Global Media LLC',
address: '789 Media Row, New York, NY 10001',
createdAt: '2024-04-08'
}
];
const mockInvoices: Invoice[] = [
{
id: 'inv1',
invoiceNumber: 'INV-2024-001',
clientId: 'c1',
status: 'paid',
issueDate: '2024-10-01',
dueDate: '2024-10-31',
lineItems: [
{id: 'li1', description: 'Web Development - October', quantity: 80, unitPrice: 150, total: 12000},
{id: 'li2', description: 'Hosting & Maintenance', quantity: 1, unitPrice: 500, total: 500}
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-10-01',
updatedAt: '2024-10-15'
},
{
id: 'inv2',
invoiceNumber: 'INV-2024-002',
clientId: 'c2',
status: 'paid',
issueDate: '2024-10-15',
dueDate: '2024-11-14',
lineItems: [{id: 'li3', description: 'Mobile App Development', quantity: 120, unitPrice: 175, total: 21000}],
subtotal: 21000,
tax: 0,
total: 21000,
createdAt: '2024-10-15',
updatedAt: '2024-11-10'
},
{
id: 'inv3',
invoiceNumber: 'INV-2024-003',
clientId: 'c1',
status: 'sent',
issueDate: '2024-11-01',
dueDate: '2024-12-01',
lineItems: [
{id: 'li4', description: 'Web Development - November', quantity: 60, unitPrice: 150, total: 9000},
{id: 'li5', description: 'API Integration', quantity: 20, unitPrice: 175, total: 3500}
],
subtotal: 12500,
tax: 0,
total: 12500,
createdAt: '2024-11-01',
updatedAt: '2024-11-01'
},
{
id: 'inv4',
invoiceNumber: 'INV-2024-004',
clientId: 'c3',
status: 'overdue',
issueDate: '2024-10-20',
dueDate: '2024-11-20',
lineItems: [{id: 'li6', description: 'Brand Identity Design', quantity: 1, unitPrice: 4500, total: 4500}],
subtotal: 4500,
tax: 0,
total: 4500,
createdAt: '2024-10-20',
updatedAt: '2024-10-20'
},
{
id: 'inv5',
invoiceNumber: 'INV-2024-005',
clientId: 'c4',
status: 'draft',
issueDate: '2024-12-01',
dueDate: '2024-12-31',
lineItems: [
{id: 'li7', description: 'Video Production', quantity: 5, unitPrice: 2000, total: 10000},
{id: 'li8', description: 'Motion Graphics', quantity: 10, unitPrice: 500, total: 5000}
],
subtotal: 15000,
tax: 0,
total: 15000,
createdAt: '2024-12-01',
updatedAt: '2024-12-01'
}
];
const initialState: InvoicesState = {
clients: mockClients,
invoices: mockInvoices,
clients: [],
invoices: [],
isLoading: false,
error: null
};

View File

@@ -1,4 +1,15 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {
assetService,
liabilityService,
snapshotService,
type Asset as ApiAsset,
type Liability as ApiLiability,
type CreateAssetRequest,
type UpdateAssetRequest,
type CreateLiabilityRequest,
type UpdateLiabilityRequest,
} from '@/lib/api/networth.service';
export interface Asset {
id: string;
@@ -32,39 +43,118 @@ export interface NetWorthState {
error: string | null;
}
// Mock data for development
const mockAssets: Asset[] = [
{id: 'a1', name: 'Chase Checking', type: 'cash', value: 12500, updatedAt: '2024-12-01'},
{id: 'a2', name: 'Ally Savings', type: 'cash', value: 35000, updatedAt: '2024-12-01'},
{id: 'a3', name: 'Fidelity 401k', type: 'investment', value: 145000, updatedAt: '2024-12-01'},
{id: 'a4', name: 'Vanguard Brokerage', type: 'investment', value: 52000, updatedAt: '2024-12-01'},
{id: 'a5', name: 'Primary Residence', type: 'property', value: 425000, updatedAt: '2024-12-01'},
{id: 'a6', name: '2021 Tesla Model 3', type: 'vehicle', value: 28000, updatedAt: '2024-12-01'}
];
const mockLiabilities: Liability[] = [
{id: 'l1', name: 'Mortgage', type: 'mortgage', balance: 320000, updatedAt: '2024-12-01'},
{id: 'l2', name: 'Auto Loan', type: 'loan', balance: 15000, updatedAt: '2024-12-01'},
{id: 'l3', name: 'Student Loans', type: 'loan', balance: 28000, updatedAt: '2024-12-01'}
];
const mockSnapshots: NetWorthSnapshot[] = [
{id: 's1', date: '2024-07-01', totalAssets: 650000, totalLiabilities: 380000, netWorth: 270000},
{id: 's2', date: '2024-08-01', totalAssets: 665000, totalLiabilities: 375000, netWorth: 290000},
{id: 's3', date: '2024-09-01', totalAssets: 680000, totalLiabilities: 370000, netWorth: 310000},
{id: 's4', date: '2024-10-01', totalAssets: 685000, totalLiabilities: 368000, netWorth: 317000},
{id: 's5', date: '2024-11-01', totalAssets: 692000, totalLiabilities: 365000, netWorth: 327000},
{id: 's6', date: '2024-12-01', totalAssets: 697500, totalLiabilities: 363000, netWorth: 334500}
];
const initialState: NetWorthState = {
assets: mockAssets,
liabilities: mockLiabilities,
snapshots: mockSnapshots,
assets: [],
liabilities: [],
snapshots: [],
isLoading: false,
error: null
};
// Helper functions to map between API and UI types
const mapApiAssetToAsset = (apiAsset: ApiAsset): Asset => ({
id: apiAsset.id,
name: apiAsset.name,
type: apiAsset.type.toLowerCase() as Asset['type'],
value: apiAsset.value,
updatedAt: apiAsset.updatedAt || new Date().toISOString(),
});
const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({
id: apiLiability.id,
name: apiLiability.name,
type: apiLiability.type.toLowerCase() as Liability['type'],
balance: apiLiability.currentBalance,
updatedAt: apiLiability.updatedAt || new Date().toISOString(),
});
// Async thunks for assets
export const fetchAssets = createAsyncThunk('netWorth/fetchAssets', async (_, {rejectWithValue}) => {
try {
const response = await assetService.getAll();
return response.assets.map(mapApiAssetToAsset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch assets');
}
});
export const createAsset = createAsyncThunk('netWorth/createAsset', async (data: CreateAssetRequest, {rejectWithValue}) => {
try {
const response = await assetService.create(data);
return mapApiAssetToAsset(response.asset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to create asset');
}
});
export const updateAsset = createAsyncThunk('netWorth/updateAsset', async ({id, data}: {id: string; data: UpdateAssetRequest}, {rejectWithValue}) => {
try {
const response = await assetService.update(id, data);
return mapApiAssetToAsset(response.asset);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to update asset');
}
});
export const deleteAsset = createAsyncThunk('netWorth/deleteAsset', async (id: string, {rejectWithValue}) => {
try {
await assetService.delete(id);
return id;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to delete asset');
}
});
// Async thunks for liabilities
export const fetchLiabilities = createAsyncThunk('netWorth/fetchLiabilities', async (_, {rejectWithValue}) => {
try {
const response = await liabilityService.getAll();
return response.liabilities.map(mapApiLiabilityToLiability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch liabilities');
}
});
export const createLiability = createAsyncThunk('netWorth/createLiability', async (data: CreateLiabilityRequest, {rejectWithValue}) => {
try {
const response = await liabilityService.create(data);
return mapApiLiabilityToLiability(response.liability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to create liability');
}
});
export const updateLiability = createAsyncThunk(
'netWorth/updateLiability',
async ({id, data}: {id: string; data: UpdateLiabilityRequest}, {rejectWithValue}) => {
try {
const response = await liabilityService.update(id, data);
return mapApiLiabilityToLiability(response.liability);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to update liability');
}
}
);
export const deleteLiability = createAsyncThunk('netWorth/deleteLiability', async (id: string, {rejectWithValue}) => {
try {
await liabilityService.delete(id);
return id;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to delete liability');
}
});
// Async thunks for snapshots
export const fetchSnapshots = createAsyncThunk('netWorth/fetchSnapshots', async (_, {rejectWithValue}) => {
try {
const response = await snapshotService.getAll();
return response.snapshots;
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch snapshots');
}
});
const netWorthSlice = createSlice({
name: 'netWorth',
initialState,
@@ -75,36 +165,84 @@ const netWorthSlice = createSlice({
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
addAsset: (state, action: PayloadAction<Asset>) => {
},
extraReducers: builder => {
// Fetch assets
builder.addCase(fetchAssets.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchAssets.fulfilled, (state, action) => {
state.isLoading = false;
state.assets = action.payload;
});
builder.addCase(fetchAssets.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create asset
builder.addCase(createAsset.fulfilled, (state, action) => {
state.assets.push(action.payload);
},
updateAsset: (state, action: PayloadAction<Asset>) => {
});
// Update asset
builder.addCase(updateAsset.fulfilled, (state, action) => {
const index = state.assets.findIndex(a => a.id === action.payload.id);
if (index !== -1) state.assets[index] = action.payload;
},
removeAsset: (state, action: PayloadAction<string>) => {
});
// Delete asset
builder.addCase(deleteAsset.fulfilled, (state, action) => {
state.assets = state.assets.filter(a => a.id !== action.payload);
},
addLiability: (state, action: PayloadAction<Liability>) => {
});
// Fetch liabilities
builder.addCase(fetchLiabilities.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchLiabilities.fulfilled, (state, action) => {
state.isLoading = false;
state.liabilities = action.payload;
});
builder.addCase(fetchLiabilities.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create liability
builder.addCase(createLiability.fulfilled, (state, action) => {
state.liabilities.push(action.payload);
},
updateLiability: (state, action: PayloadAction<Liability>) => {
});
// Update liability
builder.addCase(updateLiability.fulfilled, (state, action) => {
const index = state.liabilities.findIndex(l => l.id === action.payload.id);
if (index !== -1) state.liabilities[index] = action.payload;
},
removeLiability: (state, action: PayloadAction<string>) => {
});
// Delete liability
builder.addCase(deleteLiability.fulfilled, (state, action) => {
state.liabilities = state.liabilities.filter(l => l.id !== action.payload);
},
addSnapshot: (state, action: PayloadAction<NetWorthSnapshot>) => {
state.snapshots.push(action.payload);
},
setSnapshots: (state, action: PayloadAction<NetWorthSnapshot[]>) => {
});
// Fetch snapshots
builder.addCase(fetchSnapshots.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchSnapshots.fulfilled, (state, action) => {
state.isLoading = false;
state.snapshots = action.payload;
}
}
});
builder.addCase(fetchSnapshots.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const {setLoading, setError, addAsset, updateAsset, removeAsset, addLiability, updateLiability, removeLiability, addSnapshot, setSnapshots} =
netWorthSlice.actions;
export const {setLoading, setError} = netWorthSlice.actions;
export default netWorthSlice.reducer;

View File

@@ -1,4 +1,5 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
import {authService, type RegisterRequest, type LoginRequest} from '@/lib/api/auth.service';
export interface User {
id: string;
@@ -20,6 +21,40 @@ const initialState: UserState = {
error: null
};
// Async thunks
export const registerUser = createAsyncThunk('user/register', async (data: RegisterRequest, {rejectWithValue}) => {
try {
const response = await authService.register(data);
return response.user;
} catch (error: any) {
return rejectWithValue(error.message || 'Registration failed');
}
});
export const loginUser = createAsyncThunk('user/login', async (data: LoginRequest, {rejectWithValue}) => {
try {
const response = await authService.login(data);
return response.user;
} catch (error: any) {
return rejectWithValue(error.message || 'Login failed');
}
});
export const loadUserFromStorage = createAsyncThunk('user/loadFromStorage', async (_, {rejectWithValue}) => {
try {
const user = authService.getCurrentUser();
if (!user) {
return rejectWithValue('No user found');
}
// Verify token is still valid by fetching profile
await authService.getProfile();
return user;
} catch (error: any) {
authService.logout();
return rejectWithValue(error.message || 'Session expired');
}
});
const userSlice = createSlice({
name: 'user',
initialState,
@@ -33,6 +68,7 @@ const userSlice = createSlice({
state.error = null;
},
clearUser: state => {
authService.logout();
state.currentUser = null;
state.isAuthenticated = false;
state.error = null;
@@ -41,6 +77,54 @@ const userSlice = createSlice({
state.error = action.payload;
state.isLoading = false;
}
},
extraReducers: builder => {
// Register
builder.addCase(registerUser.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(registerUser.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
state.error = null;
});
builder.addCase(registerUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Login
builder.addCase(loginUser.pending, state => {
state.isLoading = true;
state.error = null;
});
builder.addCase(loginUser.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
state.error = null;
});
builder.addCase(loginUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Load from storage
builder.addCase(loadUserFromStorage.pending, state => {
state.isLoading = true;
});
builder.addCase(loadUserFromStorage.fulfilled, (state, action) => {
state.isLoading = false;
state.currentUser = action.payload;
state.isAuthenticated = true;
});
builder.addCase(loadUserFromStorage.rejected, state => {
state.isLoading = false;
state.currentUser = null;
state.isAuthenticated = false;
});
}
});