From 5f7957e6560ca2406d352f943d9f2a344c843721 Mon Sep 17 00:00:00 2001 From: Mainnet-ops Date: Wed, 26 Aug 2026 00:47:11 +0100 Subject: [PATCH] =?UTF-8?q?Revert=20"feat(insight-cards):=20add=20real-tim?= =?UTF-8?q?e=20agent-generated=20insight=20cards=20for=20=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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, 1 insertion(+), 962 deletions(-) delete mode 100644 backend/src/routes/insightCardsRoutes.ts delete mode 100644 backend/src/schemas/insightCardsSchema.ts delete mode 100644 backend/src/services/__tests__/insightCardsService.test.ts delete mode 100644 backend/src/services/insightCardsService.ts delete mode 100644 frontend/src/components/InsightCards.tsx delete mode 100644 frontend/src/services/insightCardsApi.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 2b69c6d6..e2a3bab4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -47,9 +47,6 @@ 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'; @@ -178,7 +175,6 @@ 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 deleted file mode 100644 index 41b57db0..00000000 --- a/backend/src/routes/insightCardsRoutes.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 00504957..00000000 --- a/backend/src/schemas/insightCardsSchema.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index ce124e48..00000000 --- a/backend/src/services/__tests__/insightCardsService.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * 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 deleted file mode 100644 index facac4fc..00000000 --- a/backend/src/services/insightCardsService.ts +++ /dev/null @@ -1,308 +0,0 @@ -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 deleted file mode 100644 index 6d1c20ce..00000000 --- a/frontend/src/components/InsightCards.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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 c2b1c670..85e30180 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,7 +1,6 @@ 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(); @@ -46,11 +45,7 @@ export default function Home() {
-
- -
- -
+
diff --git a/frontend/src/services/insightCardsApi.ts b/frontend/src/services/insightCardsApi.ts deleted file mode 100644 index 83dbd2d4..00000000 --- a/frontend/src/services/insightCardsApi.ts +++ /dev/null @@ -1,41 +0,0 @@ -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; -};