diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index e2d17ea..d649067 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,21 +1,66 @@ +import {lazy, Suspense} from 'react'; import {BrowserRouter, Routes, Route} from 'react-router-dom'; import Layout from '@/components/Layout'; -import NetWorthPage from '@/pages/NetWorthPage'; -import CashflowPage from '@/pages/CashflowPage'; -import DebtsPage from '@/pages/DebtsPage'; -import InvoicesPage from '@/pages/InvoicesPage'; -import ClientsPage from '@/pages/ClientsPage'; + +// Code splitting: lazy load route components +const NetWorthPage = lazy(() => import('@/pages/NetWorthPage')); +const CashflowPage = lazy(() => import('@/pages/CashflowPage')); +const DebtsPage = lazy(() => import('@/pages/DebtsPage')); +const InvoicesPage = lazy(() => import('@/pages/InvoicesPage')); +const ClientsPage = lazy(() => import('@/pages/ClientsPage')); + +// Simple loading fallback +const PageLoader = () => ( +
+
Loading...
+
+); export default function App() { return ( }> - } /> - } /> - } /> - } /> - } /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> diff --git a/frontend-web/src/components/ErrorBoundary.tsx b/frontend-web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..cd5db21 --- /dev/null +++ b/frontend-web/src/components/ErrorBoundary.tsx @@ -0,0 +1,76 @@ +import {Component, type ReactNode} from 'react'; +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = {hasError: false, error: null}; + } + + static getDerivedStateFromError(error: Error): State { + return {hasError: true, error}; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Log error to console in development + if (import.meta.env.DEV) { + console.error('Error caught by boundary:', error, errorInfo); + } + // In production, you would send this to an error tracking service + } + + handleReset = () => { + this.setState({hasError: false, error: null}); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ + + Something went wrong + + +

+ An unexpected error occurred. Please try refreshing the page. +

+ {import.meta.env.DEV && this.state.error && ( +
+

+ {this.state.error.message} +

+
+ )} +
+ + +
+
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend-web/src/components/dialogs/AddAssetDialog.tsx b/frontend-web/src/components/dialogs/AddAssetDialog.tsx index 16008d4..83949c7 100644 --- a/frontend-web/src/components/dialogs/AddAssetDialog.tsx +++ b/frontend-web/src/components/dialogs/AddAssetDialog.tsx @@ -5,6 +5,7 @@ import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {useAppDispatch, addAsset} from '@/store'; +import {validatePositiveNumber, validateRequired, sanitizeString} from '@/lib/validation'; interface Props { open: boolean; @@ -16,18 +17,44 @@ const assetTypes = ['cash', 'investment', 'property', 'vehicle', 'other'] as con export default function AddAssetDialog({open, onOpenChange}: Props) { const dispatch = useAppDispatch(); const [form, setForm] = useState({name: '', type: '', value: ''}); + const [errors, setErrors] = useState({name: '', value: ''}); + + const validate = (): boolean => { + const newErrors = {name: '', value: ''}; + let isValid = true; + + if (!validateRequired(form.name)) { + newErrors.name = 'Name is required'; + isValid = false; + } + + const valueNum = validatePositiveNumber(form.value); + if (valueNum === null) { + newErrors.value = 'Please enter a valid positive number'; + isValid = false; + } + + setErrors(newErrors); + return isValid; + }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (!validate()) return; + + const valueNum = validatePositiveNumber(form.value); + if (valueNum === null) return; + dispatch(addAsset({ id: crypto.randomUUID(), - name: form.name, + name: sanitizeString(form.name), type: form.type as typeof assetTypes[number], - value: parseFloat(form.value) || 0, + value: valueNum, updatedAt: new Date().toISOString(), })); onOpenChange(false); setForm({name: '', type: '', value: ''}); + setErrors({name: '', value: ''}); }; return ( @@ -42,6 +69,7 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
setForm({...form, name: e.target.value})} className="input-depth" required /> + {errors.name &&

{errors.name}

}
@@ -55,6 +83,7 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
setForm({...form, value: e.target.value})} className="input-depth" required /> + {errors.value &&

{errors.value}

}
diff --git a/frontend-web/src/components/dialogs/EditAssetDialog.tsx b/frontend-web/src/components/dialogs/EditAssetDialog.tsx new file mode 100644 index 0000000..318e1aa --- /dev/null +++ b/frontend-web/src/components/dialogs/EditAssetDialog.tsx @@ -0,0 +1,139 @@ +import {useState, useEffect} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; +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 {validatePositiveNumber, validateRequired} from '@/lib/validation'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + asset: Asset | null; +} + +const assetTypes = ['cash', 'investment', 'property', 'vehicle', 'other'] as const; + +export default function EditAssetDialog({open, onOpenChange, asset}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({name: '', type: '', value: ''}); + const [errors, setErrors] = useState({name: '', value: ''}); + + useEffect(() => { + if (asset) { + setForm({ + name: asset.name, + type: asset.type, + value: asset.value.toString(), + }); + setErrors({name: '', value: ''}); + } + }, [asset]); + + const validate = (): boolean => { + const newErrors = {name: '', value: ''}; + let isValid = true; + + if (!validateRequired(form.name)) { + newErrors.name = 'Name is required'; + isValid = false; + } + + const valueNum = validatePositiveNumber(form.value); + if (valueNum === null) { + newErrors.value = 'Please enter a valid positive number'; + isValid = false; + } + + setErrors(newErrors); + return isValid; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!asset || !validate()) return; + + const valueNum = validatePositiveNumber(form.value); + if (valueNum === null) return; + + dispatch(updateAsset({ + id: asset.id, + name: form.name.trim(), + type: form.type as typeof assetTypes[number], + value: valueNum, + updatedAt: new Date().toISOString(), + })); + onOpenChange(false); + }; + + const handleDelete = () => { + if (!asset) return; + if (confirm(`Are you sure you want to delete "${asset.name}"?`)) { + dispatch(removeAsset(asset.id)); + onOpenChange(false); + } + }; + + if (!asset) return null; + + return ( + + + + Edit Asset + Update asset details or delete this asset + +
+
+
+ + setForm({...form, name: e.target.value})} + className="input-depth" + required + /> + {errors.name &&

{errors.name}

} +
+
+ + +
+
+ + setForm({...form, value: e.target.value})} + className="input-depth" + required + /> + {errors.value &&

{errors.value}

} +
+
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/components/dialogs/EditClientDialog.tsx b/frontend-web/src/components/dialogs/EditClientDialog.tsx new file mode 100644 index 0000000..52b9a2b --- /dev/null +++ b/frontend-web/src/components/dialogs/EditClientDialog.tsx @@ -0,0 +1,175 @@ +import {useState, useEffect} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useAppDispatch, updateClient, removeClient, type Client} from '@/store'; +import {validateEmail, validatePhone, validateRequired, sanitizeString} from '@/lib/validation'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + client: Client | null; +} + +export default function EditClientDialog({open, onOpenChange, client}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({ + name: '', + email: '', + phone: '', + company: '', + address: '', + notes: '', + }); + const [errors, setErrors] = useState({name: '', email: '', phone: ''}); + + useEffect(() => { + if (client) { + setForm({ + name: client.name, + email: client.email, + phone: client.phone || '', + company: client.company || '', + address: client.address || '', + notes: client.notes || '', + }); + setErrors({name: '', email: '', phone: ''}); + } + }, [client]); + + const validate = (): boolean => { + const newErrors = {name: '', email: '', phone: ''}; + let isValid = true; + + if (!validateRequired(form.name)) { + newErrors.name = 'Name is required'; + isValid = false; + } + + if (!validateEmail(form.email)) { + newErrors.email = 'Please enter a valid email'; + isValid = false; + } + + if (form.phone && !validatePhone(form.phone)) { + newErrors.phone = 'Please enter a valid phone number'; + isValid = false; + } + + setErrors(newErrors); + return isValid; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!client || !validate()) return; + + dispatch(updateClient({ + id: client.id, + name: sanitizeString(form.name), + email: sanitizeString(form.email), + phone: form.phone ? sanitizeString(form.phone) : undefined, + company: form.company ? sanitizeString(form.company) : undefined, + address: form.address ? sanitizeString(form.address) : undefined, + notes: form.notes ? sanitizeString(form.notes) : undefined, + createdAt: client.createdAt, + })); + onOpenChange(false); + }; + + const handleDelete = () => { + if (!client) return; + if (confirm(`Are you sure you want to delete "${client.name}"? This will not delete associated invoices.`)) { + dispatch(removeClient(client.id)); + onOpenChange(false); + } + }; + + if (!client) return null; + + return ( + + + + Edit Client + Update client details or delete this client + +
+
+
+ + setForm({...form, name: e.target.value})} + className="input-depth" + required + /> + {errors.name &&

{errors.name}

} +
+
+ + setForm({...form, email: e.target.value})} + className="input-depth" + required + /> + {errors.email &&

{errors.email}

} +
+
+ + setForm({...form, phone: e.target.value})} + className="input-depth" + /> + {errors.phone &&

{errors.phone}

} +
+
+ + setForm({...form, company: e.target.value})} + className="input-depth" + /> +
+
+ + setForm({...form, address: e.target.value})} + className="input-depth" + /> +
+
+ + setForm({...form, notes: e.target.value})} + className="input-depth" + /> +
+
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx new file mode 100644 index 0000000..96c3856 --- /dev/null +++ b/frontend-web/src/components/dialogs/EditLiabilityDialog.tsx @@ -0,0 +1,143 @@ +import {useState, useEffect} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; +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 {validatePositiveNumber, validateRequired} from '@/lib/validation'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + liability: Liability | null; +} + +const liabilityTypes = ['credit_card', 'loan', 'mortgage', 'other'] as const; + +export default function EditLiabilityDialog({open, onOpenChange, liability}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({name: '', type: '', balance: ''}); + const [errors, setErrors] = useState({name: '', balance: ''}); + + useEffect(() => { + if (liability) { + setForm({ + name: liability.name, + type: liability.type, + balance: liability.balance.toString(), + }); + setErrors({name: '', balance: ''}); + } + }, [liability]); + + const validate = (): boolean => { + const newErrors = {name: '', balance: ''}; + let isValid = true; + + if (!validateRequired(form.name)) { + newErrors.name = 'Name is required'; + isValid = false; + } + + const balanceNum = validatePositiveNumber(form.balance); + if (balanceNum === null) { + newErrors.balance = 'Please enter a valid positive number'; + isValid = false; + } + + setErrors(newErrors); + return isValid; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!liability || !validate()) return; + + const balanceNum = validatePositiveNumber(form.balance); + if (balanceNum === null) return; + + dispatch(updateLiability({ + id: liability.id, + name: form.name.trim(), + type: form.type as typeof liabilityTypes[number], + balance: balanceNum, + updatedAt: new Date().toISOString(), + })); + onOpenChange(false); + }; + + const handleDelete = () => { + if (!liability) return; + if (confirm(`Are you sure you want to delete "${liability.name}"?`)) { + dispatch(removeLiability(liability.id)); + onOpenChange(false); + } + }; + + if (!liability) return null; + + return ( + + + + Edit Liability + Update liability details or delete this liability + +
+
+
+ + setForm({...form, name: e.target.value})} + className="input-depth" + required + /> + {errors.name &&

{errors.name}

} +
+
+ + +
+
+ + setForm({...form, balance: e.target.value})} + className="input-depth" + required + /> + {errors.balance &&

{errors.balance}

} +
+
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx b/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx new file mode 100644 index 0000000..b47c2fd --- /dev/null +++ b/frontend-web/src/components/dialogs/InvoiceDetailsDialog.tsx @@ -0,0 +1,162 @@ +import {useState, useEffect} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; +import {Button} from '@/components/ui/button'; +import {useAppDispatch, updateInvoiceStatus, removeInvoice, type Invoice, type Client} from '@/store'; +import {formatCurrency} from '@/lib/formatters'; +import {format} from 'date-fns'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + invoice: Invoice | null; + clients: Client[]; +} + +const statusColors: Record = { + draft: 'text-gray-400', + sent: 'text-blue-400', + paid: 'text-green-400', + overdue: 'text-red-400', + cancelled: 'text-gray-500', +}; + +export default function InvoiceDetailsDialog({open, onOpenChange, invoice, clients}: Props) { + const dispatch = useAppDispatch(); + const [selectedStatus, setSelectedStatus] = useState('draft'); + + useEffect(() => { + if (invoice) { + setSelectedStatus(invoice.status); + } + }, [invoice]); + + const handleStatusChange = () => { + if (!invoice) return; + if (selectedStatus !== invoice.status) { + dispatch(updateInvoiceStatus({id: invoice.id, status: selectedStatus})); + } + onOpenChange(false); + }; + + const handleDelete = () => { + if (!invoice) return; + if (confirm(`Are you sure you want to delete invoice ${invoice.invoiceNumber}?`)) { + dispatch(removeInvoice(invoice.id)); + onOpenChange(false); + } + }; + + if (!invoice) return null; + + const client = clients.find(c => c.id === invoice.clientId); + + return ( + + + + + Invoice {invoice.invoiceNumber} + + {invoice.status} + + + View invoice details and update status + + +
+ {/* Client Info */} +
+
+

Client

+

{client?.name || 'Unknown'}

+

{client?.email}

+
+
+

Dates

+

Issued: {format(new Date(invoice.issueDate), 'MMM d, yyyy')}

+

Due: {format(new Date(invoice.dueDate), 'MMM d, yyyy')}

+
+
+ + {/* Line Items */} +
+

Line Items

+
+ {invoice.lineItems.map(item => ( +
+
+

{item.description}

+

+ {item.quantity} × {formatCurrency(item.unitPrice)} +

+
+

{formatCurrency(item.total)}

+
+ ))} +
+
+ Subtotal + {formatCurrency(invoice.subtotal)} +
+ {invoice.tax > 0 && ( +
+ Tax + {formatCurrency(invoice.tax)} +
+ )} +
+ Total + {formatCurrency(invoice.total)} +
+
+
+
+ + {/* Notes */} + {invoice.notes && ( +
+

Notes

+

{invoice.notes}

+
+ )} + + {/* Status Update */} +
+

Update Status

+ +
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend-web/src/lib/calculations.ts b/frontend-web/src/lib/calculations.ts new file mode 100644 index 0000000..b6c3a05 --- /dev/null +++ b/frontend-web/src/lib/calculations.ts @@ -0,0 +1,59 @@ +/** + * Financial calculation utilities + */ + +import type {NetWorthSnapshot} from '@/store/slices/netWorthSlice'; + +export const calculateMonthlyChange = (snapshots: NetWorthSnapshot[]): number => { + if (snapshots.length < 2) return 0; + + const sorted = [...snapshots].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + const current = sorted[0]; + const previous = sorted[1]; + + return current.netWorth - previous.netWorth; +}; + +export const calculateYTDGrowth = (snapshots: NetWorthSnapshot[]): number => { + if (snapshots.length === 0) return 0; + + const currentYear = new Date().getFullYear(); + const sorted = [...snapshots].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + const ytdSnapshots = sorted.filter(s => new Date(s.date).getFullYear() === currentYear); + + if (ytdSnapshots.length === 0) return 0; + if (ytdSnapshots.length === 1) { + // If only one snapshot this year, compare to last snapshot of previous year + const lastYearSnapshots = sorted.filter(s => new Date(s.date).getFullYear() === currentYear - 1); + if (lastYearSnapshots.length === 0) return 0; + + const startValue = lastYearSnapshots[lastYearSnapshots.length - 1].netWorth; + const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth; + + if (startValue === 0) return 0; + return ((currentValue - startValue) / Math.abs(startValue)) * 100; + } + + const startValue = ytdSnapshots[0].netWorth; + const currentValue = ytdSnapshots[ytdSnapshots.length - 1].netWorth; + + if (startValue === 0) return 0; + return ((currentValue - startValue) / Math.abs(startValue)) * 100; +}; + +export const calculateAllTimeGrowth = (snapshots: NetWorthSnapshot[]): number => { + if (snapshots.length < 2) return 0; + + const sorted = [...snapshots].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + + if (first.netWorth === 0) return 0; + return ((last.netWorth - first.netWorth) / Math.abs(first.netWorth)) * 100; +}; + +export const calculateSavingsRate = (totalIncome: number, totalExpenses: number): number => { + if (totalIncome === 0) return 0; + return ((totalIncome - totalExpenses) / totalIncome) * 100; +}; diff --git a/frontend-web/src/lib/formatters.ts b/frontend-web/src/lib/formatters.ts new file mode 100644 index 0000000..a5f0de8 --- /dev/null +++ b/frontend-web/src/lib/formatters.ts @@ -0,0 +1,25 @@ +/** + * Currency formatting utilities + */ + +export const formatCurrency = (value: number, options?: {maximumFractionDigits?: number}): string => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: options?.maximumFractionDigits ?? 0, + }).format(value); +}; + +export const formatCurrencyCompact = (value: number): string => { + if (Math.abs(value) >= 1000000) { + return `$${(value / 1000000).toFixed(1)}M`; + } + if (Math.abs(value) >= 1000) { + return `$${(value / 1000).toFixed(0)}k`; + } + return formatCurrency(value); +}; + +export const formatPercentage = (value: number, decimals = 1): string => { + return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`; +}; diff --git a/frontend-web/src/lib/validation.ts b/frontend-web/src/lib/validation.ts new file mode 100644 index 0000000..c3cc47a --- /dev/null +++ b/frontend-web/src/lib/validation.ts @@ -0,0 +1,59 @@ +/** + * Input validation and sanitization utilities + */ + +export const sanitizeString = (input: string): string => { + // Remove potential XSS vectors while preserving legitimate content + return input + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/on\w+\s*=\s*["'][^"']*["']/gi, '') + .replace(/javascript:/gi, '') + .trim(); +}; + +export const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +export const validatePhone = (phone: string): boolean => { + // Accepts various phone formats + const phoneRegex = /^[\d\s\-\(\)\+]+$/; + return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10; +}; + +export const validateNumber = (value: string): number | null => { + const parsed = parseFloat(value); + if (isNaN(parsed)) return null; + return parsed; +}; + +export const validatePositiveNumber = (value: string): number | null => { + const num = validateNumber(value); + if (num === null || num < 0) return null; + return num; +}; + +export const validateRequired = (value: string): boolean => { + return value.trim().length > 0; +}; + +export const validateInvoiceNumber = (invoiceNumber: string, existingNumbers: string[]): boolean => { + if (!validateRequired(invoiceNumber)) return false; + // Check uniqueness + const sanitized = sanitizeString(invoiceNumber); + return !existingNumbers.some(num => num === sanitized); +}; + +export const generateInvoiceNumber = (existingNumbers: string[]): string => { + const year = new Date().getFullYear(); + let counter = 1; + let invoiceNum: string; + + do { + invoiceNum = `INV-${year}-${String(counter).padStart(3, '0')}`; + counter++; + } while (existingNumbers.includes(invoiceNum)); + + return invoiceNum; +}; diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index 7c411a8..0c1b809 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -2,6 +2,7 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; import {Provider} from 'react-redux'; import {store} from './store'; +import {ErrorBoundary} from '@/components/ErrorBoundary'; import '@fontsource-variable/funnel-sans'; import '@fontsource-variable/inter'; import './index.css'; @@ -9,8 +10,10 @@ import App from './App.tsx'; createRoot(document.getElementById('root')!).render( - - - + + + + + ); diff --git a/frontend-web/src/pages/ClientsPage.tsx b/frontend-web/src/pages/ClientsPage.tsx index a2254c7..cf901c9 100644 --- a/frontend-web/src/pages/ClientsPage.tsx +++ b/frontend-web/src/pages/ClientsPage.tsx @@ -1,11 +1,15 @@ import {useState} from 'react'; import {Card, CardContent} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; -import {useAppSelector} from '@/store'; +import {useAppSelector, type Client} from '@/store'; import AddClientDialog from '@/components/dialogs/AddClientDialog'; +import EditClientDialog from '@/components/dialogs/EditClientDialog'; +import {formatCurrency} from '@/lib/formatters'; export default function ClientsPage() { const [dialogOpen, setDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState(null); const {clients, invoices} = useAppSelector(state => state.invoices); const getClientStats = (clientId: string) => { @@ -15,11 +19,14 @@ export default function ClientsPage() { return {totalBilled, outstanding, count: clientInvoices.length}; }; - const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value); - const totalBilled = clients.reduce((sum, c) => sum + getClientStats(c.id).totalBilled, 0); const totalOutstanding = clients.reduce((sum, c) => sum + getClientStats(c.id).outstanding, 0); + const handleEditClient = (client: Client) => { + setSelectedClient(client); + setEditDialogOpen(true); + }; + return (
{/* Header + Summary inline */} @@ -28,8 +35,8 @@ export default function ClientsPage() {

Clients

Total Clients {clients.length}
-
Total Billed {fmt(totalBilled)}
-
Outstanding {fmt(totalOutstanding)}
+
Total Billed {formatCurrency(totalBilled)}
+
Outstanding {formatCurrency(totalOutstanding)}
@@ -40,7 +47,11 @@ export default function ClientsPage() { {clients.map(client => { const stats = getClientStats(client.id); return ( - + handleEditClient(client)} + >
@@ -48,13 +59,13 @@ export default function ClientsPage() {

{client.company || client.email}

{stats.outstanding > 0 && ( - {fmt(stats.outstanding)} due + {formatCurrency(stats.outstanding)} due )}

Billed

-

{fmt(stats.totalBilled)}

+

{formatCurrency(stats.totalBilled)}

Invoices

@@ -75,6 +86,7 @@ export default function ClientsPage() {
+
); } diff --git a/frontend-web/src/pages/InvoicesPage.tsx b/frontend-web/src/pages/InvoicesPage.tsx index f3099d3..fc28d78 100644 --- a/frontend-web/src/pages/InvoicesPage.tsx +++ b/frontend-web/src/pages/InvoicesPage.tsx @@ -1,14 +1,18 @@ import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; -import {useAppSelector} from '@/store'; +import {useAppSelector, type Invoice} from '@/store'; import {format} from 'date-fns'; import AddClientDialog from '@/components/dialogs/AddClientDialog'; import AddInvoiceDialog from '@/components/dialogs/AddInvoiceDialog'; +import InvoiceDetailsDialog from '@/components/dialogs/InvoiceDetailsDialog'; +import {formatCurrency} from '@/lib/formatters'; export default function InvoicesPage() { const [clientDialogOpen, setClientDialogOpen] = useState(false); const [invoiceDialogOpen, setInvoiceDialogOpen] = useState(false); + const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); + const [selectedInvoice, setSelectedInvoice] = useState(null); const {invoices, clients} = useAppSelector(state => state.invoices); const getClientName = (clientId: string) => clients.find(c => c.id === clientId)?.name ?? 'Unknown'; @@ -17,8 +21,6 @@ export default function InvoicesPage() { const totalPaid = invoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.total, 0); const overdueCount = invoices.filter(i => i.status === 'overdue').length; - const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value); - const byStatus = { overdue: invoices.filter(i => i.status === 'overdue'), sent: invoices.filter(i => i.status === 'sent'), @@ -26,6 +28,11 @@ export default function InvoicesPage() { paid: invoices.filter(i => i.status === 'paid').slice(0, 5), }; + const handleViewInvoice = (invoice: Invoice) => { + setSelectedInvoice(invoice); + setDetailsDialogOpen(true); + }; + return (
{/* Header + Summary inline */} @@ -33,8 +40,8 @@ export default function InvoicesPage() {

Invoices

-
Outstanding {fmt(totalOutstanding)}
-
Paid {fmt(totalPaid)}
+
Outstanding {formatCurrency(totalOutstanding)}
+
Paid {formatCurrency(totalPaid)}
{overdueCount > 0 &&
{overdueCount} overdue
}
@@ -56,10 +63,14 @@ export default function InvoicesPage() { ) : (
{byStatus.overdue.map(inv => ( -
+
handleViewInvoice(inv)} + >
{inv.invoiceNumber} - {fmt(inv.total)} + {formatCurrency(inv.total)}

{getClientName(inv.clientId)}

@@ -80,10 +91,14 @@ export default function InvoicesPage() { ) : (
{byStatus.sent.map(inv => ( -
+
handleViewInvoice(inv)} + >
{inv.invoiceNumber} - {fmt(inv.total)} + {formatCurrency(inv.total)}
{getClientName(inv.clientId)} @@ -107,10 +122,14 @@ export default function InvoicesPage() { ) : (
{byStatus.draft.map(inv => ( -
+
handleViewInvoice(inv)} + >
{inv.invoiceNumber} - {fmt(inv.total)} + {formatCurrency(inv.total)}

{getClientName(inv.clientId)}

@@ -131,10 +150,14 @@ export default function InvoicesPage() { ) : (
{byStatus.paid.map(inv => ( -
+
handleViewInvoice(inv)} + >
{inv.invoiceNumber} - {fmt(inv.total)} + {formatCurrency(inv.total)}

{getClientName(inv.clientId)}

@@ -147,6 +170,12 @@ export default function InvoicesPage() { +
); } diff --git a/frontend-web/src/pages/NetWorthPage.tsx b/frontend-web/src/pages/NetWorthPage.tsx index b526499..67301c8 100644 --- a/frontend-web/src/pages/NetWorthPage.tsx +++ b/frontend-web/src/pages/NetWorthPage.tsx @@ -1,15 +1,24 @@ import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; -import {useAppSelector} from '@/store'; +import {useAppSelector, useAppDispatch, addSnapshot, 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'; import AddLiabilityDialog from '@/components/dialogs/AddLiabilityDialog'; +import EditAssetDialog from '@/components/dialogs/EditAssetDialog'; +import EditLiabilityDialog from '@/components/dialogs/EditLiabilityDialog'; +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); + const [editLiabilityDialogOpen, setEditLiabilityDialogOpen] = useState(false); + const [selectedAsset, setSelectedAsset] = useState(null); + const [selectedLiability, setSelectedLiability] = useState(null); const {assets, liabilities, snapshots} = useAppSelector(state => state.netWorth); const chartData = snapshots.map(s => ({ @@ -21,8 +30,29 @@ export default function NetWorthPage() { const totalLiabilities = liabilities.reduce((sum, l) => sum + l.balance, 0); const netWorth = totalAssets - totalLiabilities; - const fmt = (value: number) => - new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value); + const monthlyChange = calculateMonthlyChange(snapshots); + const ytdGrowth = calculateYTDGrowth(snapshots); + + const handleRecordSnapshot = () => { + const snapshot = { + id: crypto.randomUUID(), + date: new Date().toISOString().split('T')[0], + totalAssets, + totalLiabilities, + netWorth, + }; + dispatch(addSnapshot(snapshot)); + }; + + const handleEditAsset = (asset: Asset) => { + setSelectedAsset(asset); + setEditAssetDialogOpen(true); + }; + + const handleEditLiability = (liability: Liability) => { + setSelectedLiability(liability); + setEditLiabilityDialogOpen(true); + }; return (
@@ -31,12 +61,12 @@ export default function NetWorthPage() {

Net Worth

-
Assets {fmt(totalAssets)}
-
Liabilities {fmt(totalLiabilities)}
-
Net {fmt(netWorth)}
+
Assets {formatCurrency(totalAssets)}
+
Liabilities {formatCurrency(totalLiabilities)}
+
Net {formatCurrency(netWorth)}
- +
@@ -57,7 +87,7 @@ export default function NetWorthPage() { [fmt(value), 'Net Worth']} + formatter={(value: number) => [formatCurrency(value), 'Net Worth']} /> @@ -71,13 +101,17 @@ export default function NetWorthPage() {

Monthly Change

-

+$7,500

+

= 0 ? 'text-green-400' : 'text-red-400'}`}> + {monthlyChange >= 0 ? '+' : ''}{formatCurrency(monthlyChange)} +

YTD Growth

-

+23.8%

+

= 0 ? 'text-green-400' : 'text-red-400'}`}> + {formatPercentage(ytdGrowth)} +

@@ -97,12 +131,16 @@ export default function NetWorthPage() {
{assets.map(asset => ( -
+
handleEditAsset(asset)} + >
{asset.name} {asset.type}
- {fmt(asset.value)} + {formatCurrency(asset.value)}
))}
@@ -118,12 +156,16 @@ export default function NetWorthPage() {
{liabilities.map(liability => ( -
+
handleEditLiability(liability)} + >
{liability.name} {liability.type.replace('_', ' ')}
- {fmt(liability.balance)} + {formatCurrency(liability.balance)}
))}
@@ -162,6 +204,8 @@ export default function NetWorthPage() { + +
); }