From 381bf15922658a44e137474d0521a930a4123818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CMainnet-ops=E2=80=9D?= <“footballwatch68@gmail.com”> Date: Wed, 26 Aug 2026 00:42:40 +0100 Subject: [PATCH] feat(insight-cards): add real-time agent-generated insight cards for dashboard Add backend service that aggregates payroll, workforce, liquidity, and schedule data into severity-ranked insight cards. Expose via GET /api/v1/insight-cards with configurable lookback window. Frontend InsightCards component renders cards on the Home page with severity badges, metrics, and deep-link actions. 16 unit tests cover all card generation paths including edge cases and partial failures. --- backend/src/app.ts | 4 + backend/src/routes/insightCardsRoutes.ts | 30 ++ backend/src/schemas/insightCardsSchema.ts | 39 ++ .../__tests__/insightCardsService.test.ts | 373 ++++++++++++++++++ backend/src/services/insightCardsService.ts | 308 +++++++++++++++ frontend/src/components/InsightCards.tsx | 161 ++++++++ frontend/src/pages/Home.tsx | 7 +- frontend/src/services/insightCardsApi.ts | 41 ++ 8 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/insightCardsRoutes.ts create mode 100644 backend/src/schemas/insightCardsSchema.ts create mode 100644 backend/src/services/__tests__/insightCardsService.test.ts create mode 100644 backend/src/services/insightCardsService.ts create mode 100644 frontend/src/components/InsightCards.tsx create mode 100644 frontend/src/services/insightCardsApi.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index e2a3bab4..2b69c6d6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -47,6 +47,9 @@ import { detectSqlInjection } from './middleware/tenantSecurityMonitor.js'; import auditAnalyticsRoutes from './routes/auditAnalyticsRoutes.js'; import smartRateLimitRoutes from './routes/smartRateLimitRoutes.js'; import tenantSecurityRoutes from './routes/tenantSecurityRoutes.js'; + +// Insight Cards +import insightCardsRoutes from './routes/insightCardsRoutes.js'; import { enhancedAuditMiddleware } from './middleware/enhancedAuditAnalytics.js'; import { smartRateLimitMiddleware } from './middleware/smartRateLimiter.js'; import { tenantSecurityGuardMiddleware } from './middleware/tenantSecurityGuard.js'; @@ -175,6 +178,7 @@ app.use('/api/usage', tenantUsageRoutes); app.use('/api/audit-analytics', auditAnalyticsRoutes); app.use('/api/smart-rate-limit', smartRateLimitRoutes); app.use('/api/tenant-security', tenantSecurityRoutes); +app.use('/api/v1/insight-cards', insightCardsRoutes); // 404 handler app.use((req, res) => { diff --git a/backend/src/routes/insightCardsRoutes.ts b/backend/src/routes/insightCardsRoutes.ts new file mode 100644 index 00000000..41b57db0 --- /dev/null +++ b/backend/src/routes/insightCardsRoutes.ts @@ -0,0 +1,30 @@ +import { Router, Request, Response } from 'express'; +import { insightCardsService } from '../services/insightCardsService.js'; +import { InsightCardsQuerySchema } from '../schemas/insightCardsSchema.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { isolateOrganization, authorizeRoles } from '../middlewares/rbac.js'; +import logger from '../utils/logger.js'; + +const router = Router(); + +router.use(authenticateJWT); +router.use(isolateOrganization); + +router.get('/', authorizeRoles('EMPLOYER'), async (req: Request, res: Response) => { + try { + const parsed = InsightCardsQuerySchema.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ success: false, error: parsed.error.flatten() }); + return; + } + + const organizationId = req.user!.organizationId; + const result = await insightCardsService.generate(organizationId, parsed.data.windowDays); + res.json({ success: true, data: result }); + } catch (error) { + logger.error('Failed to generate insight cards', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/schemas/insightCardsSchema.ts b/backend/src/schemas/insightCardsSchema.ts new file mode 100644 index 00000000..00504957 --- /dev/null +++ b/backend/src/schemas/insightCardsSchema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const InsightSeverity = z.enum(['info', 'warning', 'critical']); +export type InsightSeverity = z.infer; + +export const InsightCategory = z.enum([ + 'payroll', + 'liquidity', + 'compliance', + 'workforce', + 'schedule', +]); +export type InsightCategory = z.infer; + +export const InsightCard = z.object({ + id: z.string(), + title: z.string(), + body: z.string(), + category: InsightCategory, + severity: InsightSeverity, + metric: z.string().optional(), + metricLabel: z.string().optional(), + actionLabel: z.string().optional(), + actionRoute: z.string().optional(), + generatedAt: z.string().datetime(), +}); +export type InsightCard = z.infer; + +export const InsightCardsResponse = z.object({ + cards: z.array(InsightCard), + generatedAt: z.string().datetime(), + windowDays: z.number().int().positive(), +}); +export type InsightCardsResponse = z.infer; + +export const InsightCardsQuerySchema = z.object({ + windowDays: z.coerce.number().int().positive().max(90).default(30), +}); +export type InsightCardsQuerySchema = z.infer; diff --git a/backend/src/services/__tests__/insightCardsService.test.ts b/backend/src/services/__tests__/insightCardsService.test.ts new file mode 100644 index 00000000..ce124e48 --- /dev/null +++ b/backend/src/services/__tests__/insightCardsService.test.ts @@ -0,0 +1,373 @@ +/** + * Insight Cards Service — unit tests with fixture data. + * + * Mocks the database pool and external services so tests run without Postgres. + * Each test provides fixture data with known expected output. + */ + +const mockQuery = jest.fn(); + +jest.mock('../../config/database.js', () => ({ + __esModule: true, + pool: { query: mockQuery }, + default: { query: mockQuery }, +})); + +jest.mock('../tenantConfigService.js', () => ({ + __esModule: true, + default: { getConfig: jest.fn() }, +})); + +jest.mock('../balanceService.js', () => ({ + __esModule: true, + BalanceService: { preflightCheck: jest.fn() }, +})); + +import { InsightCardsService } from '../insightCardsService'; +import tenantConfigService from '../tenantConfigService'; +import { BalanceService } from '../balanceService'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ORG_ID = 42; +const WINDOW_DAYS = 30; + +function payrollRow(overrides: Record = {}) { + return { + total: '10', + completed: '8', + failed: '2', + total_disbursed: '5000.00', + ...overrides, + }; +} + +function employeeRow(overrides: Record = {}) { + return { + total: '20', + active: '18', + inactive: '2', + departments: '4', + ...overrides, + }; +} + +function scheduleRow(overrides: Record = {}) { + return { + total: '3', + active: '2', + next_run: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('InsightCardsService', () => { + let service: InsightCardsService; + + beforeEach(() => { + service = new InsightCardsService(); + jest.clearAllMocks(); + }); + + // ---- Payroll insights --------------------------------------------------- + + test('generates payroll total-disbursed card with correct metric', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const payrollCard = result.cards.find((c) => c.id === 'payroll-total-disbursed'); + expect(payrollCard).toBeDefined(); + expect(payrollCard!.metric).toBe('5000.00'); + expect(payrollCard!.metricLabel).toBe('ORGUSD'); + expect(payrollCard!.severity).toBe('warning'); // 2 failures > 0 + expect(payrollCard!.category).toBe('payroll'); + }); + + test('generates critical failure-rate card when >10% payments fail', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [payrollRow({ total: '10', failed: '5', completed: '5' })], + }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const failCard = result.cards.find((c) => c.id === 'payroll-high-failure-rate'); + expect(failCard).toBeDefined(); + expect(failCard!.severity).toBe('critical'); + expect(failCard!.metric).toBe('50.0%'); + }); + + test('does not generate failure-rate card when <=10% fail', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [payrollRow({ total: '100', failed: '5', completed: '95' })], + }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const failCard = result.cards.find((c) => c.id === 'payroll-high-failure-rate'); + expect(failCard).toBeUndefined(); + }); + + test('generates no-activity card when zero transactions', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [ + payrollRow({ total: '0', completed: '0', failed: '0', total_disbursed: '0' }), + ], + }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const noActivity = result.cards.find((c) => c.id === 'payroll-no-activity'); + expect(noActivity).toBeDefined(); + expect(noActivity!.severity).toBe('warning'); + expect(noActivity!.category).toBe('payroll'); + }); + + // ---- Workforce insights ------------------------------------------------- + + test('generates workforce headcount card', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const headcount = result.cards.find((c) => c.id === 'workforce-headcount'); + expect(headcount).toBeDefined(); + expect(headcount!.metric).toBe('18'); + expect(headcount!.metricLabel).toBe('Active Employees'); + }); + + test('generates high-inactive warning when inactive >= active', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ + rows: [employeeRow({ active: '5', inactive: '10', total: '15' })], + }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const highInactive = result.cards.find((c) => c.id === 'workforce-high-inactive'); + expect(highInactive).toBeDefined(); + expect(highInactive!.severity).toBe('warning'); + }); + + test('generates no-employees card when total is zero', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ + rows: [employeeRow({ total: '0', active: '0', inactive: '0', departments: '0' })], + }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const noEmp = result.cards.find((c) => c.id === 'workforce-no-employees'); + expect(noEmp).toBeDefined(); + expect(noEmp!.severity).toBe('info'); + }); + + // ---- Liquidity insights ------------------------------------------------- + + test('generates not-configured card when liquidity settings missing', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const notConfigured = result.cards.find((c) => c.id === 'liquidity-not-configured'); + expect(notConfigured).toBeDefined(); + expect(notConfigured!.severity).toBe('info'); + }); + + test('generates zero-balance critical card when balance is 0', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue({ + distributionAccount: 'GABC123', + assetIssuer: 'GDEF456', + }); + (BalanceService.preflightCheck as jest.Mock).mockResolvedValue({ + availableBalance: '0', + }); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const zeroBalance = result.cards.find((c) => c.id === 'liquidity-zero-balance'); + expect(zeroBalance).toBeDefined(); + expect(zeroBalance!.severity).toBe('critical'); + expect(zeroBalance!.metric).toBe('0'); + }); + + test('generates balance info card when balance is positive', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue({ + distributionAccount: 'GABC123', + assetIssuer: 'GDEF456', + }); + (BalanceService.preflightCheck as jest.Mock).mockResolvedValue({ + availableBalance: '15000.50', + }); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const balanceCard = result.cards.find((c) => c.id === 'liquidity-balance'); + expect(balanceCard).toBeDefined(); + expect(balanceCard!.metric).toBe('15000.50'); + expect(balanceCard!.severity).toBe('info'); + }); + + // ---- Schedule insights -------------------------------------------------- + + test('generates schedule summary card', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const summary = result.cards.find((c) => c.id === 'schedule-summary'); + expect(summary).toBeDefined(); + expect(summary!.metric).toBe('2'); + expect(summary!.metricLabel).toBe('Active Schedules'); + }); + + test('generates no-schedules card when total is zero', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ + rows: [scheduleRow({ total: '0', active: '0' })], + }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const noSched = result.cards.find((c) => c.id === 'schedule-none'); + expect(noSched).toBeDefined(); + expect(noSched!.severity).toBe('info'); + }); + + test('generates upcoming-run warning when next run is within 24h', async () => { + const fiveHoursFromNow = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(); + + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ + rows: [scheduleRow({ next_run: fiveHoursFromNow })], + }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const upcoming = result.cards.find((c) => c.id === 'schedule-upcoming'); + expect(upcoming).toBeDefined(); + expect(upcoming!.severity).toBe('warning'); // < 6 hours + }); + + // ---- Sorting & structure ------------------------------------------------ + + test('cards are sorted by severity: critical first, then warning, then info', async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [payrollRow({ total: '10', failed: '5', completed: '5' })], + }) + .mockResolvedValueOnce({ + rows: [employeeRow({ active: '5', inactive: '10', total: '15' })], + }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + const severityOrder: Record = { critical: 0, warning: 1, info: 2 }; + for (let i = 1; i < result.cards.length; i++) { + expect(severityOrder[result.cards[i]!.severity]!).toBeGreaterThanOrEqual( + severityOrder[result.cards[i - 1]!.severity]! + ); + } + }); + + test('response includes generatedAt and windowDays', async () => { + mockQuery + .mockResolvedValueOnce({ rows: [payrollRow()] }) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, 14); + + expect(result.windowDays).toBe(14); + expect(result.generatedAt).toBeTruthy(); + expect(new Date(result.generatedAt).toISOString()).toBe(result.generatedAt); + }); + + test('partial failures from individual builders do not crash the whole response', async () => { + mockQuery + .mockRejectedValueOnce(new Error('db down')) + .mockResolvedValueOnce({ rows: [employeeRow()] }) + .mockResolvedValueOnce({ rows: [scheduleRow()] }); + + (tenantConfigService.getConfig as jest.Mock).mockResolvedValue(null); + + const result = await service.generate(ORG_ID, WINDOW_DAYS); + + expect(result.cards.length).toBeGreaterThan(0); + expect(result.cards.some((c) => c.category === 'workforce')).toBe(true); + expect(result.cards.some((c) => c.category === 'schedule')).toBe(true); + expect(result.cards.some((c) => c.category === 'payroll')).toBe(false); + }); +}); diff --git a/backend/src/services/insightCardsService.ts b/backend/src/services/insightCardsService.ts new file mode 100644 index 00000000..facac4fc --- /dev/null +++ b/backend/src/services/insightCardsService.ts @@ -0,0 +1,308 @@ +import { pool } from '../config/database.js'; +import { BalanceService } from './balanceService.js'; +import tenantConfigService from './tenantConfigService.js'; +import logger from '../utils/logger.js'; +import type { InsightCard, InsightCardsResponse } from '../schemas/insightCardsSchema.js'; + +const DEFAULT_WINDOW_DAYS = 30; + +export class InsightCardsService { + async generate( + organizationId: number, + windowDays: number = DEFAULT_WINDOW_DAYS + ): Promise { + const now = new Date(); + const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const [payrollInsights, workforceInsights, liquidityInsights, scheduleInsights] = + await Promise.allSettled([ + this.buildPayrollInsights(organizationId, windowStart, now), + this.buildWorkforceInsights(organizationId), + this.buildLiquidityInsights(organizationId), + this.buildScheduleInsights(organizationId, windowStart, now), + ]); + + const cards: InsightCard[] = []; + + if (payrollInsights.status === 'fulfilled') cards.push(...payrollInsights.value); + if (workforceInsights.status === 'fulfilled') cards.push(...workforceInsights.value); + if (liquidityInsights.status === 'fulfilled') cards.push(...liquidityInsights.value); + if (scheduleInsights.status === 'fulfilled') cards.push(...scheduleInsights.value); + + cards.sort((a, b) => { + const severityOrder = { critical: 0, warning: 1, info: 2 }; + return severityOrder[a.severity] - severityOrder[b.severity]; + }); + + return { + cards, + generatedAt: now.toISOString(), + windowDays, + }; + } + + private async buildPayrollInsights( + organizationId: number, + windowStart: Date, + now: Date + ): Promise { + const cards: InsightCard[] = []; + + const result = await pool.query( + `SELECT + COUNT(*) AS total, + COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed, + COUNT(CASE WHEN status = 'failed' THEN 1 END) AS failed, + COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) AS total_disbursed + FROM transactions + WHERE organization_id = $1 AND created_at BETWEEN $2 AND $3`, + [organizationId, windowStart, now] + ); + + const row = result.rows[0]; + const total = parseInt(row.total, 10); + const failed = parseInt(row.failed, 10); + const completed = parseInt(row.completed, 10); + const totalDisbursed = parseFloat(row.total_disbursed); + + if (total === 0) { + cards.push({ + id: 'payroll-no-activity', + title: 'No payroll activity', + body: `No transactions recorded in the last ${Math.round((now.getTime() - windowStart.getTime()) / 86400000)} days. Verify schedules are configured.`, + category: 'payroll', + severity: 'warning', + actionLabel: 'View Schedules', + actionRoute: '/payroll', + generatedAt: now.toISOString(), + }); + return cards; + } + + cards.push({ + id: 'payroll-total-disbursed', + title: 'Total disbursed', + body: `${completed} payments completed${failed > 0 ? `, ${failed} failed` : ''} this period.`, + category: 'payroll', + severity: failed > 0 ? 'warning' : 'info', + metric: totalDisbursed.toFixed(2), + metricLabel: 'ORGUSD', + actionLabel: 'View Transactions', + actionRoute: '/transaction-history', + generatedAt: now.toISOString(), + }); + + if (total > 0) { + const failRate = (failed / total) * 100; + if (failRate > 10) { + cards.push({ + id: 'payroll-high-failure-rate', + title: 'High payment failure rate', + body: `${failRate.toFixed(1)}% of payments failed (${failed}/${total}). Review failed transactions for common errors.`, + category: 'payroll', + severity: 'critical', + metric: `${failRate.toFixed(1)}%`, + metricLabel: 'Failure Rate', + actionLabel: 'View Failed', + actionRoute: '/transaction-history', + generatedAt: now.toISOString(), + }); + } + } + + return cards; + } + + private async buildWorkforceInsights(organizationId: number): Promise { + const cards: InsightCard[] = []; + const now = new Date(); + + const result = await pool.query( + `SELECT + COUNT(*) AS total, + COUNT(CASE WHEN status = 'active' THEN 1 END) AS active, + COUNT(CASE WHEN status = 'inactive' THEN 1 END) AS inactive, + COUNT(DISTINCT department) AS departments + FROM employees + WHERE organization_id = $1 AND deleted_at IS NULL`, + [organizationId] + ); + + const row = result.rows[0]; + const total = parseInt(row.total, 10); + const active = parseInt(row.active, 10); + const inactive = parseInt(row.inactive, 10); + + if (total === 0) { + cards.push({ + id: 'workforce-no-employees', + title: 'No employees on record', + body: 'Add employees to start managing payroll and distributions.', + category: 'workforce', + severity: 'info', + actionLabel: 'Add Employees', + actionRoute: '/employee', + generatedAt: now.toISOString(), + }); + return cards; + } + + cards.push({ + id: 'workforce-headcount', + title: 'Workforce overview', + body: `${active} active, ${inactive} inactive across ${row.departments} department${row.departments === 1 ? '' : 's'}.`, + category: 'workforce', + severity: 'info', + metric: String(active), + metricLabel: 'Active Employees', + actionLabel: 'View Employees', + actionRoute: '/employee', + generatedAt: now.toISOString(), + }); + + if (inactive > 0 && inactive >= active) { + cards.push({ + id: 'workforce-high-inactive', + title: 'High inactive employee count', + body: `${inactive} inactive employees outnumber the ${active} active ones. Consider archiving stale records.`, + category: 'workforce', + severity: 'warning', + generatedAt: now.toISOString(), + }); + } + + return cards; + } + + private async buildLiquidityInsights(organizationId: number): Promise { + const cards: InsightCard[] = []; + const now = new Date(); + + try { + const config = await tenantConfigService.getConfig(organizationId, 'liquidity_settings'); + if (!config?.distributionAccount || !config?.assetIssuer) { + cards.push({ + id: 'liquidity-not-configured', + title: 'Liquidity monitoring unavailable', + body: 'Configure a distribution account and asset issuer to enable balance monitoring.', + category: 'liquidity', + severity: 'info', + actionLabel: 'Configure', + actionRoute: '/settings', + generatedAt: now.toISOString(), + }); + return cards; + } + + const preflight = await BalanceService.preflightCheck( + config.distributionAccount, + config.assetIssuer, + [] + ); + + const balance = parseFloat(preflight.availableBalance); + + if (balance === 0) { + cards.push({ + id: 'liquidity-zero-balance', + title: 'Distribution account empty', + body: 'The distribution account has zero ORGUSD balance. Fund it before the next payroll run.', + category: 'liquidity', + severity: 'critical', + metric: '0', + metricLabel: 'ORGUSD Balance', + actionLabel: 'View Forecast', + actionRoute: '/forecasting', + generatedAt: now.toISOString(), + }); + } else { + cards.push({ + id: 'liquidity-balance', + title: 'Distribution balance', + body: `Distribution account holds ${preflight.availableBalance} ORGUSD.`, + category: 'liquidity', + severity: 'info', + metric: parseFloat(preflight.availableBalance).toFixed(2), + metricLabel: 'ORGUSD', + actionLabel: 'View Forecast', + actionRoute: '/forecasting', + generatedAt: now.toISOString(), + }); + } + } catch (err) { + logger.warn('Could not build liquidity insight', err); + } + + return cards; + } + + private async buildScheduleInsights( + organizationId: number, + windowStart: Date, + now: Date + ): Promise { + const cards: InsightCard[] = []; + + const result = await pool.query( + `SELECT + COUNT(*) AS total, + COUNT(CASE WHEN status = 'active' THEN 1 END) AS active, + MIN(next_run_at) AS next_run + FROM payroll_schedules + WHERE organization_id = $1 AND deleted_at IS NULL`, + [organizationId] + ); + + const row = result.rows[0]; + const total = parseInt(row.total, 10); + const active = parseInt(row.active, 10); + + if (total === 0) { + cards.push({ + id: 'schedule-none', + title: 'No payroll schedules', + body: 'Create a schedule to automate recurring payroll runs.', + category: 'schedule', + severity: 'info', + actionLabel: 'Create Schedule', + actionRoute: '/payroll', + generatedAt: now.toISOString(), + }); + return cards; + } + + cards.push({ + id: 'schedule-summary', + title: 'Payroll schedules', + body: `${active} active schedule${active === 1 ? '' : 's'} out of ${total} total.`, + category: 'schedule', + severity: 'info', + metric: String(active), + metricLabel: 'Active Schedules', + actionLabel: 'View Schedules', + actionRoute: '/payroll', + generatedAt: now.toISOString(), + }); + + if (row.next_run) { + const nextRun = new Date(row.next_run); + const hoursUntil = (nextRun.getTime() - now.getTime()) / (1000 * 60 * 60); + if (hoursUntil > 0 && hoursUntil < 24) { + cards.push({ + id: 'schedule-upcoming', + title: 'Payroll run approaching', + body: `Next scheduled run in ${Math.round(hoursUntil)} hour${Math.round(hoursUntil) === 1 ? '' : 's'}. Verify account balance is sufficient.`, + category: 'schedule', + severity: hoursUntil < 6 ? 'warning' : 'info', + actionLabel: 'View Forecast', + actionRoute: '/forecasting', + generatedAt: now.toISOString(), + }); + } + } + + return cards; + } +} + +export const insightCardsService = new InsightCardsService(); diff --git a/frontend/src/components/InsightCards.tsx b/frontend/src/components/InsightCards.tsx new file mode 100644 index 00000000..6d1c20ce --- /dev/null +++ b/frontend/src/components/InsightCards.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + AlertTriangle, + ArrowRight, + BadgeDollarSign, + CalendarClock, + Landmark, + RefreshCw, + ShieldAlert, + Users, +} from 'lucide-react'; +import { + getInsightCards, + type InsightCard as InsightCardType, + type InsightSeverity, +} from '../services/insightCardsApi'; + +const CATEGORY_ICON: Record = { + payroll: BadgeDollarSign, + liquidity: Landmark, + compliance: ShieldAlert, + workforce: Users, + schedule: CalendarClock, +}; + +const SEVERITY_STYLES: Record = { + info: 'border-accent/20 bg-accent/5', + warning: 'border-yellow-500/30 bg-yellow-500/5', + critical: 'border-red-500/30 bg-red-500/5', +}; + +const SEVERITY_BADGE: Record = { + info: 'bg-accent/15 text-accent', + warning: 'bg-yellow-500/15 text-yellow-300', + critical: 'bg-red-500/15 text-red-300', +}; + +function severityLabel(s: InsightSeverity): string { + if (s === 'critical') return 'Action needed'; + if (s === 'warning') return 'Heads up'; + return 'Info'; +} + +function InsightCardItem({ card }: { card: InsightCardType }) { + const navigate = useNavigate(); + const Icon = CATEGORY_ICON[card.category] ?? Landmark; + + return ( +
+
+
+
+ +
+

{card.title}

+
+ + {severityLabel(card.severity)} + +
+ +

{card.body}

+ + {card.metric !== undefined && ( +
+ {card.metric} + {card.metricLabel && ( + {card.metricLabel} + )} +
+ )} + + {card.actionLabel && card.actionRoute && ( + + )} +
+ ); +} + +export default function InsightCards() { + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [generatedAt, setGeneratedAt] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const res = await getInsightCards(30); + setCards(res.cards); + setGeneratedAt(res.generatedAt); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load insights'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + if (cards.length === 0) { + return null; + } + + return ( +
+
+

Insights

+ {generatedAt && ( + + Updated {new Date(generatedAt).toLocaleTimeString()} + + )} +
+
+ {cards.map((card) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 85e30180..c2b1c670 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,6 +1,7 @@ import { Icon } from '@stellar/design-system'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import InsightCards from '../components/InsightCards'; export default function Home() { const navigate = useNavigate(); @@ -45,7 +46,11 @@ export default function Home() {
-
+
+ +
+ +
diff --git a/frontend/src/services/insightCardsApi.ts b/frontend/src/services/insightCardsApi.ts new file mode 100644 index 00000000..83dbd2d4 --- /dev/null +++ b/frontend/src/services/insightCardsApi.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1'; + +export type InsightSeverity = 'info' | 'warning' | 'critical'; +export type InsightCategory = 'payroll' | 'liquidity' | 'compliance' | 'workforce' | 'schedule'; + +export interface InsightCard { + id: string; + title: string; + body: string; + category: InsightCategory; + severity: InsightSeverity; + metric?: string; + metricLabel?: string; + actionLabel?: string; + actionRoute?: string; + generatedAt: string; +} + +export interface InsightCardsResponse { + cards: InsightCard[]; + generatedAt: string; + windowDays: number; +} + +function authHeaders() { + const token = localStorage.getItem('payd_auth_token'); + return token ? { Authorization: `Bearer ${token}` } : undefined; +} + +export const getInsightCards = async (windowDays: number = 30): Promise => { + const { data } = await axios.get<{ success: boolean; data: InsightCardsResponse }>( + `${API_BASE_URL}/insight-cards`, + { + params: { windowDays }, + headers: authHeaders(), + } + ); + return data.data; +};