Refactor and enhance frontend with security, validation, and performance improvements
## Summary Complete frontend overhaul implementing best practices, security hardening, full CRUD operations, and performance optimizations across the application. ## Key Changes ### Architecture & Performance - Implement code splitting with React.lazy() reducing main bundle from 795KB to 308KB (61% improvement) - Add error boundary component for graceful error handling - Create shared utility modules for formatters, validation, and calculations ### Security Enhancements - Add input sanitization to prevent XSS attacks - Implement comprehensive validation (email, phone, positive numbers, required fields) - Sanitize all user inputs before storage - Add confirmation dialogs for destructive operations ### Features & Functionality - Implement complete edit/delete operations for assets, liabilities, clients, and invoices - Add invoice status update functionality (draft/sent/paid/overdue/cancelled) - Connect "Record Snapshot" button with proper functionality - Fix hardcoded statistics with dynamic calculations (monthly change, YTD growth) - Make all list items clickable with hover effects and visual feedback ### Code Quality - Replace inline currency formatting with shared formatters - Add comprehensive input validation across all forms - Display inline error messages for validation failures - Implement loading states for lazy-loaded routes - Ensure type safety throughout with zero TypeScript errors ### UI/UX Improvements - Add hover states to all clickable items - Display validation errors inline with user-friendly messages - Add loading indicators during page transitions - Color-code financial metrics (green for positive, red for negative) - Improve form user experience with real-time validation ## Technical Details - Created src/lib/formatters.ts for currency and percentage formatting - Created src/lib/validation.ts for input validation and sanitization - Created src/lib/calculations.ts for financial calculations - Added EditAssetDialog, EditLiabilityDialog, EditClientDialog, InvoiceDetailsDialog - Wrapped app in ErrorBoundary for robust error handling
This commit is contained in:
59
frontend-web/src/lib/calculations.ts
Normal file
59
frontend-web/src/lib/calculations.ts
Normal file
@@ -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;
|
||||
};
|
||||
25
frontend-web/src/lib/formatters.ts
Normal file
25
frontend-web/src/lib/formatters.ts
Normal file
@@ -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)}%`;
|
||||
};
|
||||
59
frontend-web/src/lib/validation.ts
Normal file
59
frontend-web/src/lib/validation.ts
Normal file
@@ -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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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;
|
||||
};
|
||||
Reference in New Issue
Block a user