From 5e9ebc0aeb0b67950b270bd9ac75d9e4f78d243c Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 9 Mar 2026 19:47:13 +0100 Subject: [PATCH 1/6] feat: Add cash flow forecasting and budget alerts module - Implement backend service for historical payroll analysis - Add cash flow forecast API endpoints (forecast, historical, projections, alerts) - Create budget alert system with critical/warning/info severity levels - Build frontend dashboard with Recharts visualizations - Add trend analysis (increasing/decreasing/stable patterns) - Integrate cash flow forecast into navigation and routing - Support up to 365 days forecasting period - Mobile-responsive design with touch-friendly controls - Fix lint-staged configuration to scope eslint to frontend files only --- backend/src/app.ts | 2 + .../controllers/cashFlowForecastController.ts | 226 ++++++++++ backend/src/routes/cashFlowForecastRoutes.ts | 43 ++ .../src/services/cashFlowForecastService.ts | 378 ++++++++++++++++ frontend/src/App.tsx | 9 + frontend/src/components/AppNav.tsx | 18 + frontend/src/pages/CashFlowForecast.tsx | 418 ++++++++++++++++++ frontend/src/services/cashFlowForecastApi.ts | 245 ++++++++++ frontend/tsconfig.app.tsbuildinfo | 3 +- package.json | 7 +- 10 files changed, 1345 insertions(+), 4 deletions(-) create mode 100644 backend/src/controllers/cashFlowForecastController.ts create mode 100644 backend/src/routes/cashFlowForecastRoutes.ts create mode 100644 backend/src/services/cashFlowForecastService.ts create mode 100644 frontend/src/pages/CashFlowForecast.tsx create mode 100644 frontend/src/services/cashFlowForecastApi.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 0637c296..4af149f6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -68,6 +68,8 @@ app.use('/api', contractRoutes); app.use('/api/schedules', scheduleRoutes); app.use('/api/events', contractEventRoutes); app.use('/api/certificates', certificateRoutes); +import cashFlowForecastRoutes from './routes/cashFlowForecastRoutes.js'; +app.use('/api/cash-flow', cashFlowForecastRoutes); // 404 handler app.use((req, res) => { diff --git a/backend/src/controllers/cashFlowForecastController.ts b/backend/src/controllers/cashFlowForecastController.ts new file mode 100644 index 00000000..8cfc2b54 --- /dev/null +++ b/backend/src/controllers/cashFlowForecastController.ts @@ -0,0 +1,226 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import { CashFlowForecastService } from '../services/cashFlowForecastService.js'; +import logger from '../utils/logger.js'; +import { default as pool } from '../config/database.js'; + +const forecastQuerySchema = z.object({ + forecastDays: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 90)) + .refine((val) => val > 0 && val <= 365, { + message: 'Forecast days must be between 1 and 365', + }), + distributionAccount: z.string().length(56, 'Distribution account must be 56 characters'), + assetIssuer: z.string().length(56, 'Asset issuer must be 56 characters'), +}); + +export class CashFlowForecastController { + /** + * GET /api/cash-flow/forecast + * Generate comprehensive cash flow forecast for an organization + */ + static async getForecast(req: Request, res: Response): Promise { + try { + const organizationId = (req.user as { organizationId?: number })?.organizationId; + + if (!organizationId) { + res.status(403).json({ + error: 'User is not associated with an organization', + }); + return; + } + + const validation = forecastQuerySchema.safeParse({ + forecastDays: req.query.forecastDays, + distributionAccount: req.query.distributionAccount, + assetIssuer: req.query.assetIssuer, + }); + + if (!validation.success) { + res.status(400).json({ + error: 'Invalid request parameters', + details: validation.error.errors, + }); + return; + } + + const { forecastDays, distributionAccount, assetIssuer } = validation.data; + + const forecast = await CashFlowForecastService.generateForecast( + organizationId, + distributionAccount, + assetIssuer, + forecastDays + ); + + res.json({ + success: true, + data: forecast, + }); + } catch (error) { + logger.error('Failed to generate cash flow forecast', error); + res.status(500).json({ + error: 'Failed to generate cash flow forecast', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * GET /api/cash-flow/historical + * Get historical payroll data analysis + */ + static async getHistorical(req: Request, res: Response): Promise { + try { + const organizationId = (req.user as { organizationId?: number })?.organizationId; + + if (!organizationId) { + res.status(403).json({ + error: 'User is not associated with an organization', + }); + return; + } + + const monthsBack = req.query.monthsBack + ? parseInt(req.query.monthsBack as string, 10) + : 6; + + if (monthsBack < 1 || monthsBack > 24) { + res.status(400).json({ + error: 'Months back must be between 1 and 24', + }); + return; + } + + const historical = await CashFlowForecastService.analyzeHistoricalPayroll( + organizationId, + monthsBack + ); + + const averages = await CashFlowForecastService.calculateHistoricalAverages(organizationId); + + res.json({ + success: true, + data: { + historical, + averages, + }, + }); + } catch (error) { + logger.error('Failed to get historical payroll data', error); + res.status(500).json({ + error: 'Failed to get historical payroll data', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * GET /api/cash-flow/projections + * Get upcoming scheduled payroll projections + */ + static async getProjections(req: Request, res: Response): Promise { + try { + const organizationId = (req.user as { organizationId?: number })?.organizationId; + + if (!organizationId) { + res.status(403).json({ + error: 'User is not associated with an organization', + }); + return; + } + + const forecastDays = req.query.forecastDays + ? parseInt(req.query.forecastDays as string, 10) + : 90; + + if (forecastDays < 1 || forecastDays > 365) { + res.status(400).json({ + error: 'Forecast days must be between 1 and 365', + }); + return; + } + + const projections = await CashFlowForecastService.getUpcomingScheduledPayrolls( + organizationId, + forecastDays + ); + + res.json({ + success: true, + data: projections, + }); + } catch (error) { + logger.error('Failed to get payroll projections', error); + res.status(500).json({ + error: 'Failed to get payroll projections', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * GET /api/cash-flow/alerts + * Get budget alerts for the organization + */ + static async getAlerts(req: Request, res: Response): Promise { + try { + const organizationId = (req.user as { organizationId?: number })?.organizationId; + + if (!organizationId) { + res.status(403).json({ + error: 'User is not associated with an organization', + }); + return; + } + + const distributionAccount = req.query.distributionAccount as string; + const assetIssuer = req.query.assetIssuer as string; + + if (!distributionAccount || distributionAccount.length !== 56) { + res.status(400).json({ + error: 'Distribution account is required and must be 56 characters', + }); + return; + } + + if (!assetIssuer || assetIssuer.length !== 56) { + res.status(400).json({ + error: 'Asset issuer is required and must be 56 characters', + }); + return; + } + + const forecastDays = req.query.forecastDays + ? parseInt(req.query.forecastDays as string, 10) + : 90; + + const forecast = await CashFlowForecastService.generateForecast( + organizationId, + distributionAccount, + assetIssuer, + forecastDays + ); + + res.json({ + success: true, + data: { + alerts: forecast.alerts, + summary: { + totalAlerts: forecast.alerts.length, + criticalAlerts: forecast.alerts.filter((a) => a.severity === 'critical').length, + warningAlerts: forecast.alerts.filter((a) => a.severity === 'warning').length, + }, + }, + }); + } catch (error) { + logger.error('Failed to get budget alerts', error); + res.status(500).json({ + error: 'Failed to get budget alerts', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/backend/src/routes/cashFlowForecastRoutes.ts b/backend/src/routes/cashFlowForecastRoutes.ts new file mode 100644 index 00000000..8e33f7dc --- /dev/null +++ b/backend/src/routes/cashFlowForecastRoutes.ts @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import { CashFlowForecastController } from '../controllers/cashFlowForecastController.js'; +import { authenticateJWT } from '../middlewares/auth.js'; + +const router = Router(); + +/** + * @route GET /api/cash-flow/forecast + * @desc Generate comprehensive cash flow forecast + * @query forecastDays - Number of days to forecast (default: 90, max: 365) + * @query distributionAccount - Stellar distribution account public key (required) + * @query assetIssuer - ORGUSD asset issuer public key (required) + * @access Private (requires authentication) + */ +router.get('/forecast', authenticateJWT, CashFlowForecastController.getForecast); + +/** + * @route GET /api/cash-flow/historical + * @desc Get historical payroll data analysis + * @query monthsBack - Number of months to analyze (default: 6, max: 24) + * @access Private (requires authentication) + */ +router.get('/historical', authenticateJWT, CashFlowForecastController.getHistorical); + +/** + * @route GET /api/cash-flow/projections + * @desc Get upcoming scheduled payroll projections + * @query forecastDays - Number of days to project (default: 90, max: 365) + * @access Private (requires authentication) + */ +router.get('/projections', authenticateJWT, CashFlowForecastController.getProjections); + +/** + * @route GET /api/cash-flow/alerts + * @desc Get budget alerts for the organization + * @query forecastDays - Number of days to forecast (default: 90, max: 365) + * @query distributionAccount - Stellar distribution account public key (required) + * @query assetIssuer - ORGUSD asset issuer public key (required) + * @access Private (requires authentication) + */ +router.get('/alerts', authenticateJWT, CashFlowForecastController.getAlerts); + +export default router; diff --git a/backend/src/services/cashFlowForecastService.ts b/backend/src/services/cashFlowForecastService.ts new file mode 100644 index 00000000..db025ba0 --- /dev/null +++ b/backend/src/services/cashFlowForecastService.ts @@ -0,0 +1,378 @@ +import { default as pool } from '../config/database.js'; +import { BalanceService } from './balanceService.js'; +import logger from '../utils/logger.js'; + +export interface HistoricalPayrollData { + period: string; + totalAmount: number; + baseAmount: number; + bonusAmount: number; + runCount: number; + averageAmount: number; +} + +export interface UpcomingPayrollProjection { + date: Date; + projectedAmount: number; + scheduleId: number; + frequency: string; + confidence: 'high' | 'medium' | 'low'; +} + +export interface CashFlowForecast { + organizationId: number; + currentBalance: string; + forecastPeriod: { + start: Date; + end: Date; + }; + historicalAverage: { + weekly: number; + biweekly: number; + monthly: number; + }; + projections: UpcomingPayrollProjection[]; + totalProjectedOutflow: number; + projectedBalance: number; + alerts: BudgetAlert[]; + trendAnalysis: { + direction: 'increasing' | 'decreasing' | 'stable'; + changePercent: number; + periodCount: number; + }; +} + +export interface BudgetAlert { + type: 'insufficient_funds' | 'approaching_limit' | 'unusual_spike' | 'schedule_conflict'; + severity: 'critical' | 'warning' | 'info'; + message: string; + projectedDate: Date; + projectedAmount: number; + currentBalance: string; + shortfall?: number; +} + +export class CashFlowForecastService { + /** + * Analyze historical payroll data to calculate averages by frequency + */ + static async analyzeHistoricalPayroll( + organizationId: number, + monthsBack: number = 6 + ): Promise { + const client = await pool.connect(); + try { + const cutoffDate = new Date(); + cutoffDate.setMonth(cutoffDate.getMonth() - monthsBack); + + const query = ` + SELECT + DATE_TRUNC('month', period_start) as period, + COUNT(*) as run_count, + SUM(total_amount) as total_amount, + SUM(total_base_amount) as base_amount, + SUM(total_bonus_amount) as bonus_amount, + AVG(total_amount) as avg_amount + FROM payroll_runs + WHERE organization_id = $1 + AND status = 'completed' + AND period_start >= $2 + GROUP BY DATE_TRUNC('month', period_start) + ORDER BY period DESC + `; + + const result = await client.query(query, [organizationId, cutoffDate]); + + return result.rows.map((row) => ({ + period: row.period.toISOString().split('T')[0], + totalAmount: parseFloat(row.total_amount || '0'), + baseAmount: parseFloat(row.base_amount || '0'), + bonusAmount: parseFloat(row.bonus_amount || '0'), + runCount: parseInt(row.run_count, 10), + averageAmount: parseFloat(row.avg_amount || '0'), + })); + } catch (error) { + logger.error('Failed to analyze historical payroll', error); + throw error; + } finally { + client.release(); + } + } + + /** + * Get upcoming scheduled payrolls with projected amounts + */ + static async getUpcomingScheduledPayrolls( + organizationId: number, + forecastDays: number = 90 + ): Promise { + const client = await pool.connect(); + try { + const endDate = new Date(); + endDate.setDate(endDate.getDate() + forecastDays); + + const query = ` + SELECT + s.id, + s.frequency, + s.next_run_timestamp, + s.payment_config, + s.status + FROM schedules s + WHERE s.organization_id = $1 + AND s.status = 'active' + AND s.next_run_timestamp <= $2 + ORDER BY s.next_run_timestamp ASC + `; + + const result = await client.query(query, [organizationId, endDate]); + + const projections: UpcomingPayrollProjection[] = []; + + for (const row of result.rows) { + const paymentConfig = row.payment_config as { + recipients?: Array<{ amount: string; assetCode?: string }>; + }; + + let projectedAmount = 0; + if (paymentConfig?.recipients) { + projectedAmount = paymentConfig.recipients.reduce((sum, recipient) => { + return sum + parseFloat(recipient.amount || '0'); + }, 0); + } + + projections.push({ + date: new Date(row.next_run_timestamp), + projectedAmount, + scheduleId: row.id, + frequency: row.frequency, + confidence: this.calculateConfidence(row.frequency, row.next_run_timestamp), + }); + } + + return projections; + } catch (error) { + logger.error('Failed to get upcoming scheduled payrolls', error); + throw error; + } finally { + client.release(); + } + } + + /** + * Calculate confidence level for a projection based on schedule history + */ + private static calculateConfidence( + frequency: string, + nextRun: Date + ): 'high' | 'medium' | 'low' { + const daysUntil = Math.floor( + (nextRun.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) + ); + + if (frequency === 'once') { + return daysUntil <= 7 ? 'high' : daysUntil <= 30 ? 'medium' : 'low'; + } + + if (daysUntil <= 7) return 'high'; + if (daysUntil <= 30) return 'medium'; + return 'low'; + } + + /** + * Calculate historical averages by frequency + */ + static async calculateHistoricalAverages( + organizationId: number + ): Promise<{ weekly: number; biweekly: number; monthly: number }> { + const historical = await this.analyzeHistoricalPayroll(organizationId, 6); + + if (historical.length === 0) { + return { weekly: 0, biweekly: 0, monthly: 0 }; + } + + const monthlyTotal = historical.reduce((sum, h) => sum + h.totalAmount, 0); + const monthlyAverage = monthlyTotal / historical.length; + + return { + weekly: monthlyAverage / 4.33, + biweekly: monthlyAverage / 2.17, + monthly: monthlyAverage, + }; + } + + /** + * Generate comprehensive cash flow forecast + */ + static async generateForecast( + organizationId: number, + distributionAccount: string, + assetIssuer: string, + forecastDays: number = 90 + ): Promise { + try { + const [currentBalance, historical, projections, averages] = await Promise.all([ + BalanceService.getOrgUsdBalance(distributionAccount, assetIssuer), + this.analyzeHistoricalPayroll(organizationId, 6), + this.getUpcomingScheduledPayrolls(organizationId, forecastDays), + this.calculateHistoricalAverages(organizationId), + ]); + + const balance = parseFloat(currentBalance.balance || '0'); + const totalProjected = projections.reduce((sum, p) => sum + p.projectedAmount, 0); + const projectedBalance = balance - totalProjected; + + const trendAnalysis = this.analyzeTrend(historical); + + const alerts = this.generateBudgetAlerts( + balance, + projections, + averages, + distributionAccount + ); + + const endDate = new Date(); + endDate.setDate(endDate.getDate() + forecastDays); + + return { + organizationId, + currentBalance: currentBalance.balance || '0', + forecastPeriod: { + start: new Date(), + end: endDate, + }, + historicalAverage: averages, + projections, + totalProjectedOutflow: totalProjected, + projectedBalance, + alerts, + trendAnalysis, + }; + } catch (error) { + logger.error('Failed to generate cash flow forecast', error); + throw error; + } + } + + /** + * Analyze trend direction from historical data + */ + private static analyzeTrend(historical: HistoricalPayrollData[]): { + direction: 'increasing' | 'decreasing' | 'stable'; + changePercent: number; + periodCount: number; + } { + if (historical.length < 2) { + return { + direction: 'stable', + changePercent: 0, + periodCount: historical.length, + }; + } + + const sorted = [...historical].sort((a, b) => a.period.localeCompare(b.period)); + const firstHalf = sorted.slice(0, Math.floor(sorted.length / 2)); + const secondHalf = sorted.slice(Math.floor(sorted.length / 2)); + + const firstAvg = + firstHalf.reduce((sum, h) => sum + h.totalAmount, 0) / firstHalf.length; + const secondAvg = + secondHalf.reduce((sum, h) => sum + h.totalAmount, 0) / secondHalf.length; + + const changePercent = firstAvg > 0 ? ((secondAvg - firstAvg) / firstAvg) * 100 : 0; + + let direction: 'increasing' | 'decreasing' | 'stable'; + if (Math.abs(changePercent) < 5) { + direction = 'stable'; + } else if (changePercent > 0) { + direction = 'increasing'; + } else { + direction = 'decreasing'; + } + + return { + direction, + changePercent: Math.round(changePercent * 100) / 100, + periodCount: historical.length, + }; + } + + /** + * Generate budget alerts based on projections and current balance + */ + private static generateBudgetAlerts( + currentBalance: number, + projections: UpcomingPayrollProjection[], + averages: { weekly: number; biweekly: number; monthly: number }, + distributionAccount: string + ): BudgetAlert[] { + const alerts: BudgetAlert[] = []; + + let runningBalance = currentBalance; + + for (const projection of projections) { + runningBalance -= projection.projectedAmount; + + if (runningBalance < 0) { + alerts.push({ + type: 'insufficient_funds', + severity: 'critical', + message: `Insufficient funds projected for scheduled payroll on ${projection.date.toLocaleDateString()}. Shortfall: ${Math.abs(runningBalance).toFixed(2)}`, + projectedDate: projection.date, + projectedAmount: projection.projectedAmount, + currentBalance: currentBalance.toFixed(2), + shortfall: Math.abs(runningBalance), + }); + } else if (runningBalance < projection.projectedAmount * 1.5) { + alerts.push({ + type: 'approaching_limit', + severity: 'warning', + message: `Balance will be low after payroll on ${projection.date.toLocaleDateString()}. Consider adding funds.`, + projectedDate: projection.date, + projectedAmount: projection.projectedAmount, + currentBalance: runningBalance.toFixed(2), + }); + } + + runningBalance = Math.max(0, runningBalance); + } + + const totalProjected = projections.reduce((sum, p) => sum + p.projectedAmount, 0); + if (totalProjected > currentBalance) { + alerts.push({ + type: 'insufficient_funds', + severity: 'critical', + message: `Total projected payroll (${totalProjected.toFixed(2)}) exceeds current balance (${currentBalance.toFixed(2)})`, + projectedDate: projections[0]?.date || new Date(), + projectedAmount: totalProjected, + currentBalance: currentBalance.toFixed(2), + shortfall: totalProjected - currentBalance, + }); + } + + const monthlyProjected = projections + .filter((p) => { + const daysDiff = Math.floor( + (p.date.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) + ); + return daysDiff <= 30; + }) + .reduce((sum, p) => sum + p.projectedAmount, 0); + + if (monthlyProjected > averages.monthly * 1.2) { + alerts.push({ + type: 'unusual_spike', + severity: 'warning', + message: `Projected monthly payroll (${monthlyProjected.toFixed(2)}) is 20% higher than historical average (${averages.monthly.toFixed(2)})`, + projectedDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + projectedAmount: monthlyProjected, + currentBalance: currentBalance.toFixed(2), + }); + } + + return alerts.sort((a, b) => { + const severityOrder = { critical: 0, warning: 1, info: 2 }; + return severityOrder[a.severity] - severityOrder[b.severity]; + }); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ea3e89..279c922f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import CrossAssetPayment from './pages/CrossAssetPayment'; import TransactionHistory from './pages/TransactionHistory'; import VestingEscrow from './pages/VestingEscrow'; import RevenueSplitDashboard from './pages/RevenueSplitDashboard'; +import CashFlowForecast from './pages/CashFlowForecast'; import EmployeePortal from './pages/EmployeePortal'; import Login from './pages/Login'; @@ -191,6 +192,14 @@ function App() { } /> + {}} />}> + + + } + /> } /> } /> diff --git a/frontend/src/components/AppNav.tsx b/frontend/src/components/AppNav.tsx index decb8db2..e608bf76 100644 --- a/frontend/src/components/AppNav.tsx +++ b/frontend/src/components/AppNav.tsx @@ -13,6 +13,7 @@ import { X, Lock, PieChart, + TrendingUp, } from 'lucide-react'; import { Avatar } from './Avatar'; @@ -95,6 +96,23 @@ const AppNav: React.FC = () => { Reports + + `flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition ${ + isActive + ? 'text-(--accent) bg-white/5' + : 'text-(--muted) hover:bg-white/10 hover:text-white' + }` + } + onClick={() => setMobileOpen(false)} + > + + + + Cash Flow + + diff --git a/frontend/src/pages/CashFlowForecast.tsx b/frontend/src/pages/CashFlowForecast.tsx new file mode 100644 index 00000000..eecaafea --- /dev/null +++ b/frontend/src/pages/CashFlowForecast.tsx @@ -0,0 +1,418 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + Area, + AreaChart, +} from 'recharts'; +import { + AlertTriangle, + TrendingUp, + TrendingDown, + Minus, + Calendar, + DollarSign, + AlertCircle, + RefreshCw, +} from 'lucide-react'; +import { + getForecast, + getHistoricalData, + getAlerts, + type CashFlowForecast as ForecastType, + type BudgetAlert, + type HistoricalPayrollData, +} from '../services/cashFlowForecastApi'; +import { useNotification } from '../hooks/useNotification'; + +interface ForecastParams { + distributionAccount: string; + assetIssuer: string; + forecastDays: number; +} + +export default function CashFlowForecast() { + const { notifyError, notifySuccess } = useNotification(); + const [forecast, setForecast] = useState(null); + const [historical, setHistorical] = useState([]); + const [alerts, setAlerts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [params, setParams] = useState({ + distributionAccount: '', + assetIssuer: '', + forecastDays: 90, + }); + + const loadForecast = useCallback(async () => { + if (!params.distributionAccount || !params.assetIssuer) { + notifyError('Please provide distribution account and asset issuer'); + return; + } + + setIsLoading(true); + try { + const [forecastData, historicalData, alertsData] = await Promise.all([ + getForecast(params), + getHistoricalData(6), + getAlerts(params), + ]); + + setForecast(forecastData); + setHistorical(historicalData.historical); + setAlerts(alertsData.alerts); + notifySuccess('Cash flow forecast updated'); + } catch (error) { + notifyError( + error instanceof Error ? error.message : 'Failed to load cash flow forecast' + ); + } finally { + setIsLoading(false); + } + }, [params, notifyError, notifySuccess]); + + useEffect(() => { + void loadForecast(); + }, [loadForecast]); + + const chartData = useMemo(() => { + if (!forecast) return []; + + const projectionMap = new Map(); + forecast.projections.forEach((proj) => { + const dateKey = new Date(proj.date).toISOString().split('T')[0]; + projectionMap.set(dateKey, (projectionMap.get(dateKey) || 0) + proj.projectedAmount); + }); + + const historicalMap = new Map(); + historical.forEach((h) => { + historicalMap.set(h.period, h.totalAmount); + }); + + const allDates = new Set([ + ...Array.from(projectionMap.keys()), + ...Array.from(historicalMap.keys()), + ]); + + return Array.from(allDates) + .sort() + .map((date) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + projected: projectionMap.get(date) || 0, + historical: historicalMap.get(date) || 0, + })); + }, [forecast, historical]); + + const balanceProjectionData = useMemo(() => { + if (!forecast) return []; + + let runningBalance = parseFloat(forecast.currentBalance); + const data: Array<{ date: string; balance: number }> = [ + { date: 'Today', balance: runningBalance }, + ]; + + forecast.projections + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .forEach((proj) => { + runningBalance -= proj.projectedAmount; + data.push({ + date: new Date(proj.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + balance: Math.max(0, runningBalance), + }); + }); + + return data; + }, [forecast]); + + const getTrendIcon = () => { + if (!forecast) return null; + const { direction } = forecast.trendAnalysis; + if (direction === 'increasing') return ; + if (direction === 'decreasing') return ; + return ; + }; + + const getAlertIcon = (severity: BudgetAlert['severity']) => { + if (severity === 'critical') return ; + if (severity === 'warning') return ; + return ; + }; + + return ( +
+
+
+

Cash Flow Forecast

+

+ Analyze historical payroll data and project future cash flow requirements +

+
+
+ setParams({ ...params, distributionAccount: e.target.value })} + className="px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]" + /> + setParams({ ...params, assetIssuer: e.target.value })} + className="px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]" + /> + + setParams({ ...params, forecastDays: parseInt(e.target.value, 10) || 90 }) + } + className="px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-24 min-h-[44px]" + /> + +
+
+ + {isLoading && !forecast ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : forecast ? ( + <> + {/* Summary Cards */} +
+
+
+ Current Balance + +
+

+ {parseFloat(forecast.currentBalance).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+

ORGUSD

+
+ +
+
+ Projected Outflow + +
+

+ {forecast.totalProjectedOutflow.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+

+ {forecast.projections.length} scheduled payments +

+
+ +
+
+ Projected Balance + +
+

+ {forecast.projectedBalance.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+

After all projections

+
+ +
+
+ Trend + {getTrendIcon()} +
+

+ {forecast.trendAnalysis.direction} +

+

+ {forecast.trendAnalysis.changePercent > 0 ? '+' : ''} + {forecast.trendAnalysis.changePercent.toFixed(1)}% vs historical +

+
+
+ + {/* Budget Alerts */} + {alerts.length > 0 && ( +
+

+ + Budget Alerts ({alerts.length}) +

+
+ {alerts.map((alert) => ( +
+
+ {getAlertIcon(alert.severity)} +
+
+ + {alert.severity} + + + {new Date(alert.projectedDate).toLocaleDateString()} + +
+

{alert.message}

+ {alert.shortfall && ( +

+ Shortfall: {alert.shortfall.toFixed(2)} ORGUSD +

+ )} +
+
+
+ ))} +
+
+ )} + + {/* Charts */} +
+ {/* Cash Flow Projection Chart */} +
+

Cash Flow Projection

+ + + + + + + + + + + + + + + +
+ + {/* Historical vs Projected Chart */} +
+

Historical vs Projected

+ + + + + + + + + + + +
+
+ + {/* Historical Averages */} +
+

Historical Averages

+
+
+

Weekly Average

+

+ {forecast.historicalAverage.weekly.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+
+

Biweekly Average

+

+ {forecast.historicalAverage.biweekly.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+
+

Monthly Average

+

+ {forecast.historicalAverage.monthly.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+
+
+ + ) : ( +
+

Enter distribution account and asset issuer to load forecast

+
+ )} +
+ ); +} diff --git a/frontend/src/services/cashFlowForecastApi.ts b/frontend/src/services/cashFlowForecastApi.ts new file mode 100644 index 00000000..1297f0cb --- /dev/null +++ b/frontend/src/services/cashFlowForecastApi.ts @@ -0,0 +1,245 @@ +import axios, { type AxiosError } from 'axios'; + +const API_BASE_URL = + (import.meta.env.VITE_API_BASE_URL as string | undefined) || 'http://localhost:3001'; + +export interface HistoricalPayrollData { + period: string; + totalAmount: number; + baseAmount: number; + bonusAmount: number; + runCount: number; + averageAmount: number; +} + +export interface UpcomingPayrollProjection { + date: string; + projectedAmount: number; + scheduleId: number; + frequency: string; + confidence: 'high' | 'medium' | 'low'; +} + +export interface CashFlowForecast { + organizationId: number; + currentBalance: string; + forecastPeriod: { + start: string; + end: string; + }; + historicalAverage: { + weekly: number; + biweekly: number; + monthly: number; + }; + projections: UpcomingPayrollProjection[]; + totalProjectedOutflow: number; + projectedBalance: number; + alerts: BudgetAlert[]; + trendAnalysis: { + direction: 'increasing' | 'decreasing' | 'stable'; + changePercent: number; + periodCount: number; + }; +} + +export interface BudgetAlert { + type: 'insufficient_funds' | 'approaching_limit' | 'unusual_spike' | 'schedule_conflict'; + severity: 'critical' | 'warning' | 'info'; + message: string; + projectedDate: string; + projectedAmount: number; + currentBalance: string; + shortfall?: number; +} + +export interface ForecastParams { + forecastDays?: number; + distributionAccount: string; + assetIssuer: string; +} + +export interface HistoricalDataResponse { + success: boolean; + data: { + historical: HistoricalPayrollData[]; + averages: { + weekly: number; + biweekly: number; + monthly: number; + }; + }; +} + +export interface ForecastResponse { + success: boolean; + data: CashFlowForecast; +} + +export interface ProjectionsResponse { + success: boolean; + data: UpcomingPayrollProjection[]; +} + +export interface AlertsResponse { + success: boolean; + data: { + alerts: BudgetAlert[]; + summary: { + totalAlerts: number; + criticalAlerts: number; + warningAlerts: number; + }; + }; +} + +/** + * Get comprehensive cash flow forecast + */ +export const getForecast = async (params: ForecastParams): Promise => { + try { + const response = await axios.get(`${API_BASE_URL}/api/cash-flow/forecast`, { + params: { + forecastDays: params.forecastDays || 90, + distributionAccount: params.distributionAccount, + assetIssuer: params.assetIssuer, + }, + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}`, + }, + }); + + if (!response.data.success) { + throw new Error('Failed to fetch forecast'); + } + + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ message?: string }>; + const errorMessage = + (axiosError.response?.data as { message?: string } | undefined)?.message || + axiosError.message || + 'Failed to fetch cash flow forecast'; + const newError = new Error(errorMessage); + Object.assign(newError, { cause: error }); + throw newError; + } + throw error; + } +}; + +/** + * Get historical payroll data analysis + */ +export const getHistoricalData = async ( + monthsBack?: number +): Promise<{ historical: HistoricalPayrollData[]; averages: { weekly: number; biweekly: number; monthly: number } }> => { + try { + const response = await axios.get( + `${API_BASE_URL}/api/cash-flow/historical`, + { + params: { + monthsBack: monthsBack || 6, + }, + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}`, + }, + } + ); + + if (!response.data.success) { + throw new Error('Failed to fetch historical data'); + } + + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ message?: string }>; + const errorMessage = + (axiosError.response?.data as { message?: string } | undefined)?.message || + axiosError.message || + 'Failed to fetch historical payroll data'; + const newError = new Error(errorMessage); + Object.assign(newError, { cause: error }); + throw newError; + } + throw error; + } +}; + +/** + * Get upcoming scheduled payroll projections + */ +export const getProjections = async (forecastDays?: number): Promise => { + try { + const response = await axios.get( + `${API_BASE_URL}/api/cash-flow/projections`, + { + params: { + forecastDays: forecastDays || 90, + }, + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}`, + }, + } + ); + + if (!response.data.success) { + throw new Error('Failed to fetch projections'); + } + + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ message?: string }>; + const errorMessage = + (axiosError.response?.data as { message?: string } | undefined)?.message || + axiosError.message || + 'Failed to fetch payroll projections'; + const newError = new Error(errorMessage); + Object.assign(newError, { cause: error }); + throw newError; + } + throw error; + } +}; + +/** + * Get budget alerts + */ +export const getAlerts = async (params: ForecastParams): Promise<{ + alerts: BudgetAlert[]; + summary: { totalAlerts: number; criticalAlerts: number; warningAlerts: number }; +}> => { + try { + const response = await axios.get(`${API_BASE_URL}/api/cash-flow/alerts`, { + params: { + forecastDays: params.forecastDays || 90, + distributionAccount: params.distributionAccount, + assetIssuer: params.assetIssuer, + }, + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}`, + }, + }); + + if (!response.data.success) { + throw new Error('Failed to fetch alerts'); + } + + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ message?: string }>; + const errorMessage = + (axiosError.response?.data as { message?: string } | undefined)?.message || + axiosError.message || + 'Failed to fetch budget alerts'; + const newError = new Error(errorMessage); + Object.assign(newError, { cause: error }); + throw newError; + } + throw error; + } +}; diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 86f5d229..d928fe0f 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1,2 +1 @@ -{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/applayout.tsx","./src/components/appnav.tsx","./src/components/autosaveindicator.tsx","./src/components/avatar.tsx","./src/components/avatarupload.tsx","./src/components/csvuploader.tsx","./src/components/connectaccount.tsx","./src/components/countdowntimer.tsx","./src/components/employeelist.tsx","./src/components/errorboundary.tsx","./src/components/errorfallback.tsx","./src/components/feeestimationpanel.tsx","./src/components/onboardingtour.tsx","./src/components/schedulingwizard.tsx","./src/components/themetoggle.tsx","./src/components/transactionsimulationpanel.tsx","./src/components/walletqrcode.tsx","./src/components/vesting/vestinggrantform.tsx","./src/components/vesting/vestinggrantlist.tsx","./src/hooks/useautosave.ts","./src/hooks/useemployeeportal.ts","./src/hooks/usefeeestimation.ts","./src/hooks/usenotification.ts","./src/hooks/usesocket.ts","./src/hooks/usetheme.ts","./src/hooks/usetransactionsimulation.ts","./src/hooks/usewallet.ts","./src/hooks/usewalletsigning.ts","./src/pages/adminpanel.tsx","./src/pages/authcallback.tsx","./src/pages/crossassetpayment.tsx","./src/pages/customreportbuilder.tsx","./src/pages/debugger.tsx","./src/pages/employeeentry.tsx","./src/pages/employeeportal.tsx","./src/pages/feeestimation.tsx","./src/pages/helpcenter.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/payrollscheduler.tsx","./src/pages/settings.tsx","./src/pages/transactionhistory.tsx","./src/pages/vestingescrow.tsx","./src/providers/notificationprovider.tsx","./src/providers/socketprovider.tsx","./src/providers/themeprovider.tsx","./src/providers/walletprovider.tsx","./src/services/anchor.ts","./src/services/auditapi.ts","./src/services/currencyconversion.ts","./src/services/feeestimation.ts","./src/services/pathfinding.ts","./src/services/stellar.ts","./src/services/transactionsimulation.ts","./src/utils/imageoptimization.ts"],"errors":true,"version":"5.9.3"} -{"root":["./src/App.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/AppLayout.tsx","./src/components/AppNav.tsx","./src/components/AutosaveIndicator.tsx","./src/components/Avatar.tsx","./src/components/AvatarUpload.tsx","./src/components/BulkPaymentStatusTracker.tsx","./src/components/CSVUploader.tsx","./src/components/ConnectAccount.tsx","./src/components/ContractUpgradeTab.tsx","./src/components/CountdownTimer.tsx","./src/components/EmployeeList.tsx","./src/components/ErrorBoundary.tsx","./src/components/ErrorFallback.tsx","./src/components/FeeEstimationPanel.tsx","./src/components/OnboardingTour.tsx","./src/components/SchedulingWizard.tsx","./src/components/ThemeToggle.tsx","./src/components/TransactionSimulationPanel.tsx","./src/components/UpgradeConfirmModal.tsx","./src/components/WalletExtensionBanner.tsx","./src/components/WalletQRCode.tsx","./src/hooks/useAutosave.ts","./src/hooks/useEmployeePortal.ts","./src/hooks/useFeeEstimation.ts","./src/hooks/useNotification.ts","./src/hooks/useSocket.ts","./src/hooks/useSorobanContract.ts","./src/hooks/useTheme.ts","./src/hooks/useTransactionSimulation.ts","./src/hooks/useWallet.ts","./src/hooks/useWalletSigning.ts","./src/pages/AdminPanel.tsx","./src/pages/AuthCallback.tsx","./src/pages/CrossAssetPayment.tsx","./src/pages/CustomReportBuilder.tsx","./src/pages/Debugger.tsx","./src/pages/EmployeeEntry.tsx","./src/pages/EmployeePortal.tsx","./src/pages/FeeEstimation.tsx","./src/pages/HelpCenter.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PayrollScheduler.tsx","./src/pages/RevenueSplitDashboard.tsx","./src/pages/Settings.tsx","./src/pages/TransactionHistory.tsx","./src/providers/NotificationProvider.tsx","./src/providers/SocketProvider.tsx","./src/providers/ThemeProvider.tsx","./src/providers/WalletProvider.tsx","./src/services/anchor.ts","./src/services/auditApi.ts","./src/services/bulkPaymentStatus.ts","./src/services/contractUpgrade.ts","./src/services/contracts.example.tsx","./src/services/contracts.ts","./src/services/contracts.types.ts","./src/services/crossAssetPayment.ts","./src/services/currencyConversion.ts","./src/services/feeEstimation.ts","./src/services/pathfinding.ts","./src/services/revenueSplit.ts","./src/services/stellar.ts","./src/services/transactionHistory.ts","./src/services/transactionSimulation.ts","./src/utils/imageOptimization.ts"],"version":"5.9.3"} +{"root":["./src/App.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/AppLayout.tsx","./src/components/AppNav.tsx","./src/components/AutosaveIndicator.tsx","./src/components/Avatar.tsx","./src/components/AvatarUpload.tsx","./src/components/BulkPaymentStatusTracker.tsx","./src/components/CSVUploader.tsx","./src/components/CertificateDownloadButton.tsx","./src/components/ConnectAccount.tsx","./src/components/ContractUpgradeTab.tsx","./src/components/CountdownTimer.tsx","./src/components/EmployeeList.tsx","./src/components/ErrorBoundary.tsx","./src/components/ErrorFallback.tsx","./src/components/FeeEstimationPanel.tsx","./src/components/OnboardingTour.tsx","./src/components/SchedulingWizard.tsx","./src/components/ThemeToggle.tsx","./src/components/TransactionSimulationPanel.tsx","./src/components/UpgradeConfirmModal.tsx","./src/components/WalletExtensionBanner.tsx","./src/components/WalletQRCode.tsx","./src/components/vesting/VestingGrantForm.tsx","./src/components/vesting/VestingGrantList.tsx","./src/hooks/useAutosave.ts","./src/hooks/useEmployeePortal.ts","./src/hooks/useFeeEstimation.ts","./src/hooks/useNotification.ts","./src/hooks/useSocket.ts","./src/hooks/useSorobanContract.ts","./src/hooks/useTheme.ts","./src/hooks/useTransactionSimulation.ts","./src/hooks/useWallet.ts","./src/hooks/useWalletSigning.ts","./src/pages/AdminPanel.tsx","./src/pages/AuthCallback.tsx","./src/pages/CashFlowForecast.tsx","./src/pages/CrossAssetPayment.tsx","./src/pages/CustomReportBuilder.tsx","./src/pages/Debugger.tsx","./src/pages/EmployeeEntry.tsx","./src/pages/EmployeePortal.tsx","./src/pages/FeeEstimation.tsx","./src/pages/HelpCenter.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PayrollScheduler.tsx","./src/pages/RevenueSplitDashboard.tsx","./src/pages/Settings.tsx","./src/pages/TransactionHistory.tsx","./src/pages/VestingEscrow.tsx","./src/providers/NotificationProvider.tsx","./src/providers/SocketProvider.tsx","./src/providers/ThemeProvider.tsx","./src/providers/WalletProvider.tsx","./src/services/anchor.ts","./src/services/auditApi.ts","./src/services/bulkPaymentStatus.ts","./src/services/cashFlowForecastApi.ts","./src/services/certificateApi.ts","./src/services/contractUpgrade.ts","./src/services/contracts.example.tsx","./src/services/contracts.ts","./src/services/contracts.types.ts","./src/services/crossAssetPayment.ts","./src/services/currencyConversion.ts","./src/services/feeEstimation.ts","./src/services/pathfinding.ts","./src/services/revenueSplit.ts","./src/services/scheduleApi.ts","./src/services/stellar.ts","./src/services/transactionHistory.ts","./src/services/transactionSimulation.ts","./src/utils/imageOptimization.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/package.json b/package.json index f013f641..37900bbc 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,11 @@ "vite-plugin-wasm": "^3.5.0" }, "lint-staged": { - "**/*": [ - "eslint --fix --no-warn-ignored", + "frontend/**/*.{ts,tsx}": [ + "bash -c 'cd frontend && eslint --fix \"$@\"' --", + "prettier --write --ignore-unknown" + ], + "**/*.{json,md,yml,yaml}": [ "prettier --write --ignore-unknown" ] } From ac0701d73351b704d9a1d08548ca3f5c7b4cb83e Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 9 Mar 2026 20:01:38 +0100 Subject: [PATCH 2/6] fix: Format code with Prettier to pass CI checks --- frontend/src/pages/CashFlowForecast.tsx | 8 ++++---- frontend/src/services/cashFlowForecastApi.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/CashFlowForecast.tsx b/frontend/src/pages/CashFlowForecast.tsx index eecaafea..f83a9209 100644 --- a/frontend/src/pages/CashFlowForecast.tsx +++ b/frontend/src/pages/CashFlowForecast.tsx @@ -68,9 +68,7 @@ export default function CashFlowForecast() { setAlerts(alertsData.alerts); notifySuccess('Cash flow forecast updated'); } catch (error) { - notifyError( - error instanceof Error ? error.message : 'Failed to load cash flow forecast' - ); + notifyError(error instanceof Error ? error.message : 'Failed to load cash flow forecast'); } finally { setIsLoading(false); } @@ -410,7 +408,9 @@ export default function CashFlowForecast() { ) : (
-

Enter distribution account and asset issuer to load forecast

+

+ Enter distribution account and asset issuer to load forecast +

)}
diff --git a/frontend/src/services/cashFlowForecastApi.ts b/frontend/src/services/cashFlowForecastApi.ts index 1297f0cb..9ccef1e1 100644 --- a/frontend/src/services/cashFlowForecastApi.ts +++ b/frontend/src/services/cashFlowForecastApi.ts @@ -134,7 +134,10 @@ export const getForecast = async (params: ForecastParams): Promise => { +): Promise<{ + historical: HistoricalPayrollData[]; + averages: { weekly: number; biweekly: number; monthly: number }; +}> => { try { const response = await axios.get( `${API_BASE_URL}/api/cash-flow/historical`, @@ -171,7 +174,9 @@ export const getHistoricalData = async ( /** * Get upcoming scheduled payroll projections */ -export const getProjections = async (forecastDays?: number): Promise => { +export const getProjections = async ( + forecastDays?: number +): Promise => { try { const response = await axios.get( `${API_BASE_URL}/api/cash-flow/projections`, @@ -208,7 +213,9 @@ export const getProjections = async (forecastDays?: number): Promise => { From 560aaae9b651326f430b7fb22f922717f88ba328 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 9 Mar 2026 22:36:39 +0100 Subject: [PATCH 3/6] fix: Add missing CashFlowForecast import in App.tsx --- frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6410ae8..8d2b5e68 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import AdminPanel from './pages/AdminPanel'; import VestingEscrow from './pages/VestingEscrow'; import RevenueSplitDashboard from './pages/RevenueSplitDashboard'; import Forecasting from './pages/Forecasting'; +import CashFlowForecast from './pages/CashFlowForecast'; import EmployeePortal from './pages/EmployeePortal'; import Login from './pages/Login'; From 6276a15b4fbf40be631cb67211ae11ac1d4d2856 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Mon, 9 Mar 2026 23:00:42 +0100 Subject: [PATCH 4/6] chore: Update tsconfig build info --- frontend/tsconfig.app.tsbuildinfo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index d928fe0f..2a43f87d 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/AppLayout.tsx","./src/components/AppNav.tsx","./src/components/AutosaveIndicator.tsx","./src/components/Avatar.tsx","./src/components/AvatarUpload.tsx","./src/components/BulkPaymentStatusTracker.tsx","./src/components/CSVUploader.tsx","./src/components/CertificateDownloadButton.tsx","./src/components/ConnectAccount.tsx","./src/components/ContractUpgradeTab.tsx","./src/components/CountdownTimer.tsx","./src/components/EmployeeList.tsx","./src/components/ErrorBoundary.tsx","./src/components/ErrorFallback.tsx","./src/components/FeeEstimationPanel.tsx","./src/components/OnboardingTour.tsx","./src/components/SchedulingWizard.tsx","./src/components/ThemeToggle.tsx","./src/components/TransactionSimulationPanel.tsx","./src/components/UpgradeConfirmModal.tsx","./src/components/WalletExtensionBanner.tsx","./src/components/WalletQRCode.tsx","./src/components/vesting/VestingGrantForm.tsx","./src/components/vesting/VestingGrantList.tsx","./src/hooks/useAutosave.ts","./src/hooks/useEmployeePortal.ts","./src/hooks/useFeeEstimation.ts","./src/hooks/useNotification.ts","./src/hooks/useSocket.ts","./src/hooks/useSorobanContract.ts","./src/hooks/useTheme.ts","./src/hooks/useTransactionSimulation.ts","./src/hooks/useWallet.ts","./src/hooks/useWalletSigning.ts","./src/pages/AdminPanel.tsx","./src/pages/AuthCallback.tsx","./src/pages/CashFlowForecast.tsx","./src/pages/CrossAssetPayment.tsx","./src/pages/CustomReportBuilder.tsx","./src/pages/Debugger.tsx","./src/pages/EmployeeEntry.tsx","./src/pages/EmployeePortal.tsx","./src/pages/FeeEstimation.tsx","./src/pages/HelpCenter.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PayrollScheduler.tsx","./src/pages/RevenueSplitDashboard.tsx","./src/pages/Settings.tsx","./src/pages/TransactionHistory.tsx","./src/pages/VestingEscrow.tsx","./src/providers/NotificationProvider.tsx","./src/providers/SocketProvider.tsx","./src/providers/ThemeProvider.tsx","./src/providers/WalletProvider.tsx","./src/services/anchor.ts","./src/services/auditApi.ts","./src/services/bulkPaymentStatus.ts","./src/services/cashFlowForecastApi.ts","./src/services/certificateApi.ts","./src/services/contractUpgrade.ts","./src/services/contracts.example.tsx","./src/services/contracts.ts","./src/services/contracts.types.ts","./src/services/crossAssetPayment.ts","./src/services/currencyConversion.ts","./src/services/feeEstimation.ts","./src/services/pathfinding.ts","./src/services/revenueSplit.ts","./src/services/scheduleApi.ts","./src/services/stellar.ts","./src/services/transactionHistory.ts","./src/services/transactionSimulation.ts","./src/utils/imageOptimization.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/AppLayout.tsx","./src/components/AppNav.tsx","./src/components/AutosaveIndicator.tsx","./src/components/Avatar.tsx","./src/components/AvatarUpload.tsx","./src/components/BulkPaymentStatusTracker.tsx","./src/components/CSVUploader.tsx","./src/components/CertificateDownloadButton.tsx","./src/components/ConnectAccount.tsx","./src/components/ContractUpgradeTab.tsx","./src/components/CountdownTimer.tsx","./src/components/DashboardSidebar.tsx","./src/components/DashboardTopBar.tsx","./src/components/EmployeeList.tsx","./src/components/EmployerLayout.tsx","./src/components/ErrorBoundary.tsx","./src/components/ErrorFallback.tsx","./src/components/FeeEstimationPanel.tsx","./src/components/OnboardingTour.tsx","./src/components/SchedulingWizard.tsx","./src/components/ThemeToggle.tsx","./src/components/TransactionSimulationPanel.tsx","./src/components/UpgradeConfirmModal.tsx","./src/components/WalletExtensionBanner.tsx","./src/components/WalletQRCode.tsx","./src/components/vesting/VestingGrantForm.tsx","./src/components/vesting/VestingGrantList.tsx","./src/hooks/useAutosave.ts","./src/hooks/useEmployeePortal.ts","./src/hooks/useFeeEstimation.ts","./src/hooks/useNotification.ts","./src/hooks/useSocket.ts","./src/hooks/useSorobanContract.ts","./src/hooks/useTheme.ts","./src/hooks/useTransactionSimulation.ts","./src/hooks/useWallet.ts","./src/hooks/useWalletSigning.ts","./src/pages/AdminPanel.tsx","./src/pages/AuthCallback.tsx","./src/pages/CashFlowForecast.tsx","./src/pages/CrossAssetPayment.tsx","./src/pages/CustomReportBuilder.tsx","./src/pages/Debugger.tsx","./src/pages/EmployeeEntry.tsx","./src/pages/EmployeePortal.tsx","./src/pages/FeeEstimation.tsx","./src/pages/Forecasting.tsx","./src/pages/HelpCenter.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PayrollScheduler.tsx","./src/pages/RevenueSplitDashboard.tsx","./src/pages/Settings.tsx","./src/pages/TransactionHistory.tsx","./src/pages/VestingEscrow.tsx","./src/providers/NotificationProvider.tsx","./src/providers/SocketProvider.tsx","./src/providers/ThemeProvider.tsx","./src/providers/WalletProvider.tsx","./src/services/anchor.ts","./src/services/auditApi.ts","./src/services/bulkPaymentStatus.ts","./src/services/cashFlowForecastApi.ts","./src/services/certificateApi.ts","./src/services/contractUpgrade.ts","./src/services/contracts.example.tsx","./src/services/contracts.ts","./src/services/contracts.types.ts","./src/services/crossAssetPayment.ts","./src/services/currencyConversion.ts","./src/services/feeEstimation.ts","./src/services/forecastApi.ts","./src/services/pathfinding.ts","./src/services/revenueSplit.ts","./src/services/scheduleApi.ts","./src/services/stellar.ts","./src/services/transactionHistory.ts","./src/services/transactionSimulation.ts","./src/utils/imageOptimization.ts"],"version":"5.9.3"} \ No newline at end of file From f740de8a6960d20b1b496619db7981f04f26bdb0 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Tue, 10 Mar 2026 04:57:39 +0100 Subject: [PATCH 5/6] fix: Wrap fetchEmployees in useCallback to fix useEffect dependency warning --- frontend/src/pages/EmployeeEntry.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index a698027f..493eafd7 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Icon, Button, Card, Input, Select, Alert } from '@stellar/design-system'; import { EmployeeList } from '../components/EmployeeList'; import { AutosaveIndicator } from '../components/AutosaveIndicator'; @@ -71,7 +71,7 @@ export default function EmployeeEntry() { pagination?: unknown; } - const fetchEmployees = async () => { + const fetchEmployees = useCallback(async () => { try { setLoading(true); const response = await api.get('/employees'); @@ -90,11 +90,11 @@ export default function EmployeeEntry() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { void fetchEmployees(); - }, []); + }, [fetchEmployees]); useEffect(() => { const saved = loadSavedData(); From 28701f9fc2c68dda9b29a0dd2491fbc01e6eac0e Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Tue, 10 Mar 2026 05:01:22 +0100 Subject: [PATCH 6/6] fix: Remove unused EmployeeApiItem interface and use EmployeesApiResponse --- frontend/src/pages/EmployeeEntry.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index 98036281..0384e061 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -28,18 +28,6 @@ interface EmployeeItem { status?: 'Active' | 'Inactive'; } -// Shape of an employee record returned by the backend API -interface EmployeeApiItem { - id: number | string; - first_name: string; - last_name: string; - email: string; - position?: string; - job_title?: string; - wallet_address?: string; - status?: string; -} - const initialFormState: EmployeeFormState = { fullName: '', walletAddress: '', @@ -86,9 +74,7 @@ export default function EmployeeEntry() { const fetchEmployees = useCallback(async () => { try { setLoading(true); - const response = await api.get<{ data: EmployeeApiItem[]; pagination: unknown }>( - '/employees' - ); + const response = await api.get('/employees'); // Backend returns { data: [...], pagination: {...} } const mapped: EmployeeItem[] = response.data.data.map((emp) => ({ id: String(emp.id),