diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 47ee768f..d6bd4554 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -1,3 +1,4 @@ +import { getPayrollAnalytics } from '../services/payrollAnalyticsService.js'; import { Request, Response, Router } from 'express'; import { payrollQueryService } from '../services/payroll-query.service.js'; import logger from '../utils/logger.js'; @@ -428,4 +429,25 @@ router.get('/cache/stats', (req: Request, res: Response) => { } }); +/** + * GET /api/payroll/analytics + * Employer-only, 12 calendar months of settled real payroll transactions. + * Tenant identity comes from the verified JWT; clients cannot select an + * arbitrary organization's payroll data with a query parameter. + */ +router.get('/analytics', authorizeRoles('EMPLOYER'), async (req: Request, res: Response) => { + const organizationId = req.user?.organizationId; + if (!Number.isSafeInteger(organizationId) || !organizationId || organizationId < 1) { + return res.status(403).json({ error: 'An authenticated organization is required' }); + } + + try { + const analytics = await getPayrollAnalytics(organizationId); + return res.json({ success: true, data: analytics }); + } catch (error) { + logger.error('GET /api/payroll/analytics failed', error); + return res.status(500).json({ success: false, error: 'Unable to load payroll analytics' }); + } +}); + export default router; diff --git a/backend/src/services/payrollAnalyticsService.ts b/backend/src/services/payrollAnalyticsService.ts new file mode 100644 index 00000000..804c6d2f --- /dev/null +++ b/backend/src/services/payrollAnalyticsService.ts @@ -0,0 +1,90 @@ +import { pool } from '../config/database.js'; + +/** + * Completed real payroll transactions, grouped in PostgreSQL (not synthetic + * forecast points). Every query is restricted to the caller's verified tenant. + * + * Never add monetary amounts from different asset codes: the UI filters to one + * selected currency before drawing an amount-based trend or department chart. + * The currency breakdown reports transaction *counts*, not mixed-asset sums. + */ +export interface PayrollMonthlyRow { + month: string; + currency: string; + total: string; +} +export interface PayrollCurrencyRow { + currency: string; + paymentCount: string; +} +export interface PayrollDepartmentRow { + department: string; + currency: string; + total: string; +} +export interface PayrollAnalytics { + monthly: PayrollMonthlyRow[]; + currencies: PayrollCurrencyRow[]; + departments: PayrollDepartmentRow[]; + windowMonths: 12; +} + +export async function getPayrollAnalytics(organizationId: number): Promise { + if (!Number.isSafeInteger(organizationId) || organizationId < 1) { + throw new TypeError('Authenticated organization ID must be a positive integer'); + } + const periodStart = + "date_trunc('month', CURRENT_TIMESTAMP) - INTERVAL '11 months'"; + + // Include 12 calendar months including the current month. Only settled + // outbound payroll payments and bonuses count; exclude failed/refunded items. + const [monthly, currency, department] = await Promise.all([ + pool.query( + `SELECT to_char(date_trunc('month', t.created_at), 'YYYY-MM') AS month, + t.asset_code AS currency, + SUM(t.amount)::text AS total + FROM transactions t + WHERE t.organization_id = $1 + AND t.status = 'completed' + AND t.transaction_type IN ('payment', 'bonus') + AND t.created_at >= ${periodStart} + GROUP BY date_trunc('month', t.created_at), t.asset_code + ORDER BY date_trunc('month', t.created_at), t.asset_code`, + [organizationId] + ), + pool.query( + `SELECT t.asset_code AS currency, + COUNT(*)::text AS "paymentCount" + FROM transactions t + WHERE t.organization_id = $1 + AND t.status = 'completed' + AND t.transaction_type IN ('payment', 'bonus') + AND t.created_at >= ${periodStart} + GROUP BY t.asset_code + ORDER BY t.asset_code`, + [organizationId] + ), + pool.query( + `SELECT COALESCE(NULLIF(BTRIM(e.department), ''), 'Unassigned') AS department, + t.asset_code AS currency, + SUM(t.amount)::text AS total + FROM transactions t + LEFT JOIN employees e + ON e.id = t.employee_id AND e.organization_id = t.organization_id + WHERE t.organization_id = $1 + AND t.status = 'completed' + AND t.transaction_type IN ('payment', 'bonus') + AND t.created_at >= ${periodStart} + GROUP BY COALESCE(NULLIF(BTRIM(e.department), ''), 'Unassigned'), t.asset_code + ORDER BY department, t.asset_code`, + [organizationId] + ), + ]); + + return { + monthly: monthly.rows, + currencies: currency.rows, + departments: department.rows, + windowMonths: 12, + }; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index acc3dfc6..378dc1b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import Forecasting from './pages/Forecasting'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useEffect } from 'react'; import Home from './pages/Home'; @@ -97,6 +98,14 @@ function App() { } /> + }> + + + } + /> { + const now = new Date(); + return Array.from({ length: 12 }, (_, i) => { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11 + i, 1)); + return { + key: date.toISOString().slice(0, 7), + label: date.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' }), + }; + }); +} + +export default function PayrollAnalyticsCharts() { + const [analytics, setAnalytics] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currency, setCurrency] = useState(''); + const [requestCount, setRequestCount] = useState(0); + + useEffect(() => { + let active = true; + setIsLoading(true); + setError(null); + void getPayrollAnalytics() + .then((result) => { + if (!active) return; + setAnalytics(result); + setCurrency((previous) => + result.currencies.some((row) => row.currency === previous) + ? previous + : result.currencies[0]?.currency || '' + ); + }) + .catch((reason: unknown) => { + if (active) { + setAnalytics(null); + setError(reason instanceof Error ? reason.message : 'Unable to load payroll analytics'); + } + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [requestCount]); + + const currencyMix = useMemo( + () => + (analytics?.currencies || []) + .map((row) => ({ + currency: row.currency, + count: chartNumber(row.paymentCount), + })) + .filter((row): row is { currency: string; count: number } => + row.count !== null && row.count > 0 + ), + [analytics] + ); + + const selectedTrend = useMemo(() => { + const rows = new Map( + (analytics?.monthly || []) + .filter((row) => row.currency === currency) + .map((row) => [row.month, chartNumber(row.total)]) + ); + return lastTwelveMonths().map(({ key, label }) => ({ + month: key, + label, + total: rows.get(key) ?? 0, + })); + }, [analytics, currency]); + + const departmentCosts = useMemo( + () => + (analytics?.departments || []) + .filter((row) => row.currency === currency) + .map((row) => ({ department: row.department, total: chartNumber(row.total) })) + .filter((row): row is { department: string; total: number } => row.total !== null) + .sort((a, b) => b.total - a.total), + [analytics, currency] + ); + + const hasSettledPayments = currencyMix.length > 0; + const currencyDescription = currency || 'selected asset'; + const lineCaption = `Settled payroll cost in ${currencyDescription} by month; last 12 calendar months`; + const pieCaption = 'Number of completed payroll payments by currency; amounts are not combined across assets'; + const barCaption = `Completed payroll cost by department in ${currencyDescription}`; + + return ( +
+
+
+

+ Historical payroll analytics +

+

+ Live, completed payroll transactions from your organization over 12 months. +

+
+
+ + + +
+
+ + {isLoading ? ( +

Loading settled payroll records…

+ ) : error ? ( +

Payroll analytics error: {error}

+ ) : !hasSettledPayments ? ( +

+ No completed payroll transactions are available for this organization in the last 12 months. +

+ ) : ( +
+
+
+ 12-month payroll trend ({currencyDescription}) +
+
+ + + + + + [Number(value).toLocaleString(), currencyDescription]} /> + + + +
+ + + + {selectedTrend.map((point) => ( + + ))} +
{lineCaption}
MonthCost ({currencyDescription})
{point.label}{point.total}
+
+ +
+
+ Payment distribution by currency (transaction count) +
+
+ + + + {currencyMix.map((entry, index) => ( + + ))} + + [Number(value).toLocaleString(), 'Payments']} /> + + + +
+ + + + {currencyMix.map((row) => ( + + ))} +
{pieCaption}
CurrencyPayments
{row.currency}{row.count}
+
+ +
+
+ Cost by department ({currencyDescription}) +
+ {departmentCosts.length ? ( + <> +
+ + + + + + [Number(value).toLocaleString(), currencyDescription]} /> + + + +
+ + + + {departmentCosts.map((row) => ( + + ))} +
{barCaption}
DepartmentCost ({currencyDescription})
{row.department}{row.total}
+ + ) : ( +

No department costs in the selected currency.

+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Forecasting.tsx b/frontend/src/pages/Forecasting.tsx index a5ad4d0a..768a2f76 100644 --- a/frontend/src/pages/Forecasting.tsx +++ b/frontend/src/pages/Forecasting.tsx @@ -1,3 +1,4 @@ +import PayrollAnalyticsCharts from '../components/PayrollAnalyticsCharts'; import { useEffect, useMemo, useState } from 'react'; import { CartesianGrid, @@ -227,6 +228,8 @@ export default function Forecasting() { + + Liquidity Settings diff --git a/frontend/src/services/payrollAnalyticsApi.ts b/frontend/src/services/payrollAnalyticsApi.ts new file mode 100644 index 00000000..235d8bc0 --- /dev/null +++ b/frontend/src/services/payrollAnalyticsApi.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +const configuredApiBase = + (import.meta.env.VITE_API_BASE_URL as string | undefined) || + 'http://localhost:3001'; +const normalizedApiBase = configuredApiBase.replace(/\/$/, ''); +const apiRoot = normalizedApiBase.endsWith('/api') + ? normalizedApiBase + : `${normalizedApiBase}/api`; + +export interface PayrollMonthlyRow { + month: string; + currency: string; + total: string; +} +export interface PayrollCurrencyRow { + currency: string; + paymentCount: string; +} +export interface PayrollDepartmentRow { + department: string; + currency: string; + total: string; +} +export interface PayrollAnalytics { + monthly: PayrollMonthlyRow[]; + currencies: PayrollCurrencyRow[]; + departments: PayrollDepartmentRow[]; + windowMonths: 12; +} + +export async function getPayrollAnalytics(): Promise { + const token = localStorage.getItem('payd_auth_token') || localStorage.getItem('accessToken'); + const { data } = await axios.get<{ success: boolean; data: PayrollAnalytics }>( + `${apiRoot}/payroll/analytics`, + { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + } + ); + if (!data.success || !data.data) { + throw new Error('Payroll analytics could not be loaded'); + } + return data.data; +}