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:
1
frontend-web/.env.example
Normal file
1
frontend-web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
4
frontend-web/.gitignore
vendored
4
frontend-web/.gitignore
vendored
@@ -12,6 +12,10 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
frontend-web/src/lib/api/auth.service.ts
Normal file
71
frontend-web/src/lib/api/auth.service.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
88
frontend-web/src/lib/api/cashflow.service.ts
Normal file
88
frontend-web/src/lib/api/cashflow.service.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
110
frontend-web/src/lib/api/client.ts
Normal file
110
frontend-web/src/lib/api/client.ts
Normal 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);
|
||||
144
frontend-web/src/lib/api/networth.service.ts
Normal file
144
frontend-web/src/lib/api/networth.service.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
37
frontend-web/src/lib/api/token.ts
Normal file
37
frontend-web/src/lib/api/token.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user