Autosave: 20260404-034952
This commit is contained in:
parent
dd3585bf57
commit
4f12eae1aa
@ -36,6 +36,8 @@ const Departments = db.departments;
|
|||||||
|
|
||||||
const RolePermissions = db.role_permissions;
|
const RolePermissions = db.role_permissions;
|
||||||
|
|
||||||
|
const Permissions = db.permissions;
|
||||||
|
|
||||||
const ApprovalWorkflows = db.approval_workflows;
|
const ApprovalWorkflows = db.approval_workflows;
|
||||||
|
|
||||||
const ApprovalSteps = db.approval_steps;
|
const ApprovalSteps = db.approval_steps;
|
||||||
|
|||||||
@ -121,6 +121,8 @@ const documentsRoutes = require('./routes/documents');
|
|||||||
|
|
||||||
const compliance_alertsRoutes = require('./routes/compliance_alerts');
|
const compliance_alertsRoutes = require('./routes/compliance_alerts');
|
||||||
|
|
||||||
|
const executive_summaryRoutes = require('./routes/executive_summary');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@ -277,6 +279,8 @@ app.use('/api/documents', passport.authenticate('jwt', {session: false}), docume
|
|||||||
|
|
||||||
app.use('/api/compliance_alerts', passport.authenticate('jwt', {session: false}), compliance_alertsRoutes);
|
app.use('/api/compliance_alerts', passport.authenticate('jwt', {session: false}), compliance_alertsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/executive-summary', passport.authenticate('jwt', {session: false}), executive_summaryRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
969
backend/src/routes/executive_summary.js
Normal file
969
backend/src/routes/executive_summary.js
Normal file
@ -0,0 +1,969 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const { Op } = db.Sequelize;
|
||||||
|
|
||||||
|
const scopeByOrganization = (currentUser, fieldName = 'organizationId') => {
|
||||||
|
if (currentUser?.app_role?.globalAccess) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = currentUser?.organization?.id || currentUser?.organizationId || currentUser?.organizationsId;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[fieldName]: organizationId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const parsedValue = Number(value || 0);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedValue)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sumCurrency = async (model, field, currency, where, organizationField = 'organizationId') => {
|
||||||
|
const value = await model.sum(field, {
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(where.currentUser, organizationField),
|
||||||
|
...where.filters,
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return toNumber(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKSPACE_ROLES = {
|
||||||
|
superAdmin: 'Super Administrator',
|
||||||
|
administrator: 'Administrator',
|
||||||
|
directorGeneral: 'Director General',
|
||||||
|
financeDirector: 'Finance Director',
|
||||||
|
procurementLead: 'Procurement Lead',
|
||||||
|
complianceAuditLead: 'Compliance and Audit Lead',
|
||||||
|
projectDeliveryLead: 'Project Delivery Lead',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrencyValue = (value, currency) =>
|
||||||
|
new Intl.NumberFormat(currency === 'CDF' ? 'fr-CD' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(toNumber(value));
|
||||||
|
|
||||||
|
const formatMetricValue = (summary, metricKey) => {
|
||||||
|
switch (metricKey) {
|
||||||
|
case 'approvedBudget':
|
||||||
|
case 'committedBudget':
|
||||||
|
case 'disbursedBudget':
|
||||||
|
case 'budgetVariance':
|
||||||
|
return `${formatCurrencyValue(summary[metricKey]?.USD, 'USD')} / ${formatCurrencyValue(summary[metricKey]?.CDF, 'CDF')}`;
|
||||||
|
case 'averageProjectProgress':
|
||||||
|
return `${toNumber(summary[metricKey])}%`;
|
||||||
|
default:
|
||||||
|
return `${toNumber(summary[metricKey])}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCollectionCount = (datasets, collectionKey) => {
|
||||||
|
const collection = datasets[collectionKey];
|
||||||
|
|
||||||
|
if (Array.isArray(collection)) {
|
||||||
|
return collection.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspacePayloadConfigs = {
|
||||||
|
[WORKSPACE_ROLES.superAdmin]: {
|
||||||
|
summaryMetricKeys: ['activeProjects', 'pendingApprovals', 'openRiskAlerts', 'contractsNearingExpiry', 'unreadNotifications', 'vendorComplianceAlerts'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'super-admin-approvals',
|
||||||
|
title: 'Workflow backlog requiring oversight',
|
||||||
|
metricKey: 'pendingApprovals',
|
||||||
|
note: 'Pending approvals across the accessible ERP scope that may require escalation or reassignment.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'super-admin-alerts',
|
||||||
|
title: 'Open risk exposure',
|
||||||
|
metricKey: 'openRiskAlerts',
|
||||||
|
note: 'High and critical alerts still unresolved across contracts, vendors, and operational records.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'super-admin-projects',
|
||||||
|
title: 'Live execution footprint',
|
||||||
|
metricKey: 'activeProjects',
|
||||||
|
note: 'Projects currently moving through the institution and contributing to workload volume.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'super-admin-notifications',
|
||||||
|
title: 'Unread notices',
|
||||||
|
metricKey: 'unreadNotifications',
|
||||||
|
note: 'Unread workflow, control, and system notices that may hide operational friction.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'super-admin-approval-queue',
|
||||||
|
title: 'Escalation approval inbox',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'Oldest pending decisions visible from the platform oversight layer.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'super-admin-risk-watch',
|
||||||
|
title: 'Platform red-flag watchlist',
|
||||||
|
collectionKey: 'riskPanel',
|
||||||
|
note: 'The most severe open alerts currently demanding follow-up.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'super-admin-notification-watch',
|
||||||
|
title: 'Unread institutional notices',
|
||||||
|
collectionKey: 'recentNotifications',
|
||||||
|
note: 'Recent notifications that may indicate configuration or workflow issues.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.administrator]: {
|
||||||
|
summaryMetricKeys: ['pendingApprovals', 'unreadNotifications', 'contractsNearingExpiry', 'openRiskAlerts', 'activeProjects', 'procurementPipeline'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'admin-approvals',
|
||||||
|
title: 'Approvals awaiting operational follow-through',
|
||||||
|
metricKey: 'pendingApprovals',
|
||||||
|
note: 'Work items that often surface workflow setup gaps, delegation issues, or stalled administrative action.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-notifications',
|
||||||
|
title: 'Unread admin-facing notices',
|
||||||
|
metricKey: 'unreadNotifications',
|
||||||
|
note: 'Recent notifications that can point to user support, assignment, or setup problems.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-contracts',
|
||||||
|
title: 'Contracts nearing renewal attention',
|
||||||
|
metricKey: 'contractsNearingExpiry',
|
||||||
|
note: 'Active contracts nearing expiry and likely to trigger administrative coordination.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-pipeline',
|
||||||
|
title: 'Procurement workload in motion',
|
||||||
|
metricKey: 'procurementPipeline',
|
||||||
|
note: 'Requisitions advancing through review, tendering, or award that can pressure operations teams.',
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'admin-approval-queue',
|
||||||
|
title: 'Operational approval queue',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'A quick read on which approvals need routing or user follow-up.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-procurement-queue',
|
||||||
|
title: 'Live procurement queue',
|
||||||
|
collectionKey: 'procurementQueue',
|
||||||
|
note: 'Requisitions that may be blocked by setup, documentation, or coordination issues.',
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-notification-watch',
|
||||||
|
title: 'Recent administrative notices',
|
||||||
|
collectionKey: 'recentNotifications',
|
||||||
|
note: 'Recent events to help admins spot support and process issues early.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.directorGeneral]: {
|
||||||
|
summaryMetricKeys: ['approvedBudget', 'committedBudget', 'disbursedBudget', 'pendingApprovals', 'highRiskProjects', 'contractsNearingExpiry'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'dg-budget',
|
||||||
|
title: 'Approved budget envelope',
|
||||||
|
metricKey: 'approvedBudget',
|
||||||
|
note: 'Current allocation envelope available for strategic oversight and institutional steering.',
|
||||||
|
href: '/allocations/allocations-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dg-disbursed',
|
||||||
|
title: 'Disbursements already released',
|
||||||
|
metricKey: 'disbursedBudget',
|
||||||
|
note: 'Payments processed so far, useful for comparing commitment and delivery momentum.',
|
||||||
|
href: '/payments/payments-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dg-risk-projects',
|
||||||
|
title: 'Projects under elevated risk',
|
||||||
|
metricKey: 'highRiskProjects',
|
||||||
|
note: 'Projects flagged high or critical risk and likely to require executive attention.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dg-contracts',
|
||||||
|
title: 'Contracts nearing decision point',
|
||||||
|
metricKey: 'contractsNearingExpiry',
|
||||||
|
note: 'Active contracts approaching expiry that may affect program continuity or exposure.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'dg-contract-watch',
|
||||||
|
title: 'Executive contract watchlist',
|
||||||
|
collectionKey: 'contractWatchlist',
|
||||||
|
note: 'Contracts closest to expiry or requiring strategic follow-up.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dg-risk-watch',
|
||||||
|
title: 'Strategic risk watchlist',
|
||||||
|
collectionKey: 'riskPanel',
|
||||||
|
note: 'High-severity alerts that can affect institutional delivery credibility.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dg-rollout-watch',
|
||||||
|
title: 'Province rollout overview',
|
||||||
|
collectionKey: 'provinceRollout',
|
||||||
|
note: 'Delivery footprint by province to help spot uneven execution patterns.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.financeDirector]: {
|
||||||
|
summaryMetricKeys: ['approvedBudget', 'committedBudget', 'disbursedBudget', 'overduePayments', 'pendingApprovals', 'unreadNotifications'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'finance-approved',
|
||||||
|
title: 'Approved budget base',
|
||||||
|
metricKey: 'approvedBudget',
|
||||||
|
note: 'The fiscal envelope currently approved and available for expenditure control.',
|
||||||
|
href: '/allocations/allocations-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance-committed',
|
||||||
|
title: 'Committed demand',
|
||||||
|
metricKey: 'committedBudget',
|
||||||
|
note: 'Requisition value moving toward tender, award, or contract conversion.',
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance-disbursed',
|
||||||
|
title: 'Disbursed amount',
|
||||||
|
metricKey: 'disbursedBudget',
|
||||||
|
note: 'Processed payments already released, useful for cash-control tracking.',
|
||||||
|
href: '/payments/payments-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance-overdue',
|
||||||
|
title: 'Aging payment requests',
|
||||||
|
metricKey: 'overduePayments',
|
||||||
|
note: 'Submitted or approved requests older than 30 days and likely creating vendor pressure.',
|
||||||
|
href: '/payment_requests/payment_requests-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'finance-payment-watch',
|
||||||
|
title: 'Payment control watchlist',
|
||||||
|
collectionKey: 'recentNotifications',
|
||||||
|
note: 'Recent notices that may affect settlement timing or finance follow-through.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance-approval-watch',
|
||||||
|
title: 'Approvals blocking spend',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'Pending approvals that can delay commitments, invoicing, or disbursement.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance-contract-watch',
|
||||||
|
title: 'Contract value watchlist',
|
||||||
|
collectionKey: 'topContracts',
|
||||||
|
note: 'Largest contracts in scope for exposure review and budget tracking.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.procurementLead]: {
|
||||||
|
summaryMetricKeys: ['procurementPipeline', 'pendingApprovals', 'contractsNearingExpiry', 'vendorComplianceAlerts', 'openRiskAlerts', 'activeProjects'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'procurement-pipeline',
|
||||||
|
title: 'Live requisition pipeline',
|
||||||
|
metricKey: 'procurementPipeline',
|
||||||
|
note: 'Requisitions actively progressing through review, tender, and award stages.',
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'procurement-approvals',
|
||||||
|
title: 'Approvals slowing procurement',
|
||||||
|
metricKey: 'pendingApprovals',
|
||||||
|
note: 'Pending workflow actions that can delay conversion from request to tender or contract.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'procurement-contracts',
|
||||||
|
title: 'Contracts close to expiry',
|
||||||
|
metricKey: 'contractsNearingExpiry',
|
||||||
|
note: 'Active contracts approaching expiry and likely to require extension or replacement planning.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'procurement-vendor-alerts',
|
||||||
|
title: 'Vendor compliance issues',
|
||||||
|
metricKey: 'vendorComplianceAlerts',
|
||||||
|
note: 'Open missing-document or expiry alerts that may block award or execution readiness.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'procurement-queue',
|
||||||
|
title: 'Priority procurement queue',
|
||||||
|
collectionKey: 'procurementQueue',
|
||||||
|
note: 'Newest or most urgent requisitions requiring action from the procurement desk.',
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'procurement-contract-watch',
|
||||||
|
title: 'Expiring contract watchlist',
|
||||||
|
collectionKey: 'contractWatchlist',
|
||||||
|
note: 'Contracts most likely to create continuity or sourcing pressure soon.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'procurement-approval-watch',
|
||||||
|
title: 'Approval blockers to clear',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'Pending approvals tied to the demand-to-award flow.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.complianceAuditLead]: {
|
||||||
|
summaryMetricKeys: ['openRiskAlerts', 'vendorComplianceAlerts', 'contractsNearingExpiry', 'pendingApprovals', 'overduePayments', 'unreadNotifications'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'compliance-risks',
|
||||||
|
title: 'Open control alerts',
|
||||||
|
metricKey: 'openRiskAlerts',
|
||||||
|
note: 'High and critical alerts requiring audit or compliance follow-up.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance-vendors',
|
||||||
|
title: 'Vendor evidence gaps',
|
||||||
|
metricKey: 'vendorComplianceAlerts',
|
||||||
|
note: 'Vendor-related missing-document and expiry issues that can undermine compliance posture.',
|
||||||
|
href: '/vendors/vendors-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance-contracts',
|
||||||
|
title: 'Contracts nearing obligation review',
|
||||||
|
metricKey: 'contractsNearingExpiry',
|
||||||
|
note: 'Expiring contracts that may require evidence review, amendments, or closure checks.',
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance-payments',
|
||||||
|
title: 'Aging payment exposure',
|
||||||
|
metricKey: 'overduePayments',
|
||||||
|
note: 'Older payment requests that can indicate weak controls or missing approvals.',
|
||||||
|
href: '/payment_requests/payment_requests-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'compliance-risk-watch',
|
||||||
|
title: 'Highest-severity red flags',
|
||||||
|
collectionKey: 'riskPanel',
|
||||||
|
note: 'Open alerts with the strongest likelihood of control failure or audit finding.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance-approval-watch',
|
||||||
|
title: 'Approvals with control exposure',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'Pending approvals where evidence, delegation, or routing should be verified.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance-notification-watch',
|
||||||
|
title: 'Recent control notices',
|
||||||
|
collectionKey: 'recentNotifications',
|
||||||
|
note: 'Notifications that can help trace exceptions and follow-up points.',
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[WORKSPACE_ROLES.projectDeliveryLead]: {
|
||||||
|
summaryMetricKeys: ['activeProjects', 'averageProjectProgress', 'highRiskProjects', 'pendingApprovals', 'overduePayments', 'unreadNotifications'],
|
||||||
|
focusCards: [
|
||||||
|
{
|
||||||
|
key: 'delivery-projects',
|
||||||
|
title: 'Projects in execution',
|
||||||
|
metricKey: 'activeProjects',
|
||||||
|
note: 'Active projects currently moving through implementation and field follow-through.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery-progress',
|
||||||
|
title: 'Average execution progress',
|
||||||
|
metricKey: 'averageProjectProgress',
|
||||||
|
note: 'Average completion across approved and active projects in the accessible delivery scope.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery-risks',
|
||||||
|
title: 'Projects under elevated risk',
|
||||||
|
metricKey: 'highRiskProjects',
|
||||||
|
note: 'Projects marked high or critical risk and likely to affect delivery commitments.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery-payments',
|
||||||
|
title: 'Payment requests creating delivery lag',
|
||||||
|
metricKey: 'overduePayments',
|
||||||
|
note: 'Aging payment requests that can block contractors, milestones, or field mobilization.',
|
||||||
|
href: '/payment_requests/payment_requests-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
watchlistCards: [
|
||||||
|
{
|
||||||
|
key: 'delivery-rollout-watch',
|
||||||
|
title: 'Province rollout snapshot',
|
||||||
|
collectionKey: 'provinceRollout',
|
||||||
|
note: 'Provincial delivery concentration and completion spread across the current portfolio.',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery-risk-watch',
|
||||||
|
title: 'Delivery risk watchlist',
|
||||||
|
collectionKey: 'riskPanel',
|
||||||
|
note: 'Open alerts most likely to disrupt project execution.',
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery-approval-watch',
|
||||||
|
title: 'Approvals affecting execution',
|
||||||
|
collectionKey: 'approvalQueue',
|
||||||
|
note: 'Pending approvals that can delay works, procurement, or milestone completion.',
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWorkspacePayload = (roleName, summary, datasets) => {
|
||||||
|
const roleConfig = workspacePayloadConfigs[roleName] || workspacePayloadConfigs[WORKSPACE_ROLES.projectDeliveryLead];
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleName: roleName || WORKSPACE_ROLES.projectDeliveryLead,
|
||||||
|
summaryMetricKeys: roleConfig.summaryMetricKeys,
|
||||||
|
focusCards: roleConfig.focusCards.map((card) => ({
|
||||||
|
key: card.key,
|
||||||
|
title: card.title,
|
||||||
|
value: formatMetricValue(summary, card.metricKey),
|
||||||
|
note: card.note,
|
||||||
|
href: card.href,
|
||||||
|
})),
|
||||||
|
watchlistCards: roleConfig.watchlistCards.map((card) => ({
|
||||||
|
key: card.key,
|
||||||
|
title: card.title,
|
||||||
|
count: getCollectionCount(datasets, card.collectionKey),
|
||||||
|
note: card.note,
|
||||||
|
href: card.href,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const currentUser = req.currentUser;
|
||||||
|
const now = new Date();
|
||||||
|
const contractExpiryWindow = new Date(now);
|
||||||
|
const overduePaymentThreshold = new Date(now);
|
||||||
|
|
||||||
|
contractExpiryWindow.setDate(contractExpiryWindow.getDate() + 60);
|
||||||
|
overduePaymentThreshold.setDate(overduePaymentThreshold.getDate() - 30);
|
||||||
|
|
||||||
|
const allocationFilters = {
|
||||||
|
currentUser,
|
||||||
|
filters: {
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['approved', 'active'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requisitionFilters = {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['submitted', 'under_review', 'approved', 'in_tender', 'awarded', 'converted_to_contract'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentFilters = {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'processed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const [
|
||||||
|
approvedBudgetUSD,
|
||||||
|
approvedBudgetCDF,
|
||||||
|
committedBudgetUSD,
|
||||||
|
committedBudgetCDF,
|
||||||
|
disbursedBudgetUSD,
|
||||||
|
disbursedBudgetCDF,
|
||||||
|
activeProjects,
|
||||||
|
procurementPipeline,
|
||||||
|
pendingApprovals,
|
||||||
|
contractsNearingExpiry,
|
||||||
|
overduePayments,
|
||||||
|
vendorComplianceAlerts,
|
||||||
|
openRiskAlerts,
|
||||||
|
unreadNotifications,
|
||||||
|
averageProjectProgress,
|
||||||
|
highRiskProjects,
|
||||||
|
topContracts,
|
||||||
|
contractWatchlist,
|
||||||
|
approvalQueue,
|
||||||
|
procurementQueue,
|
||||||
|
riskPanel,
|
||||||
|
rolloutProjects,
|
||||||
|
recentNotifications,
|
||||||
|
] = await Promise.all([
|
||||||
|
sumCurrency(db.allocations, 'amount', 'USD', allocationFilters, 'organizationsId'),
|
||||||
|
sumCurrency(db.allocations, 'amount', 'CDF', allocationFilters, 'organizationsId'),
|
||||||
|
sumCurrency(db.requisitions, 'estimated_amount', 'USD', { currentUser, filters: requisitionFilters }),
|
||||||
|
sumCurrency(db.requisitions, 'estimated_amount', 'CDF', { currentUser, filters: requisitionFilters }),
|
||||||
|
sumCurrency(db.payments, 'amount', 'USD', { currentUser, filters: paymentFilters }),
|
||||||
|
sumCurrency(db.payments, 'amount', 'CDF', { currentUser, filters: paymentFilters }),
|
||||||
|
db.projects.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.requisitions.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['submitted', 'under_review', 'approved', 'in_tender', 'awarded'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.approvals.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.contracts.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'active',
|
||||||
|
end_date: {
|
||||||
|
[Op.between]: [now, contractExpiryWindow],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.payment_requests.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['submitted', 'under_review', 'approved', 'batched'],
|
||||||
|
},
|
||||||
|
requested_at: {
|
||||||
|
[Op.lte]: overduePaymentThreshold,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.compliance_alerts.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['open', 'acknowledged'],
|
||||||
|
},
|
||||||
|
alert_type: {
|
||||||
|
[Op.in]: ['vendor_document_expiry', 'missing_document'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.compliance_alerts.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['open', 'acknowledged'],
|
||||||
|
},
|
||||||
|
severity: {
|
||||||
|
[Op.in]: ['high', 'critical'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.notifications.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.projects.aggregate('completion_percent', 'avg', {
|
||||||
|
plain: true,
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['approved', 'active', 'on_hold', 'completed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.projects.count({
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
risk_level: {
|
||||||
|
[Op.in]: ['high', 'critical'],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['approved', 'active', 'on_hold'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.contracts.findAll({
|
||||||
|
attributes: ['id', 'contract_number', 'title', 'contract_value', 'currency', 'end_date', 'status'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['active', 'suspended', 'completed', 'expired'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.vendors,
|
||||||
|
as: 'vendor',
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['contract_value', 'DESC']],
|
||||||
|
limit: 5,
|
||||||
|
}),
|
||||||
|
db.contracts.findAll({
|
||||||
|
attributes: ['id', 'contract_number', 'title', 'contract_value', 'currency', 'end_date', 'status'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'active',
|
||||||
|
end_date: {
|
||||||
|
[Op.between]: [now, contractExpiryWindow],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.vendors,
|
||||||
|
as: 'vendor',
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['end_date', 'ASC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
db.approvals.findAll({
|
||||||
|
attributes: ['id', 'record_type', 'record_key', 'requested_at', 'status'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.approval_workflows,
|
||||||
|
as: 'workflow',
|
||||||
|
attributes: ['name', 'module'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.approval_steps,
|
||||||
|
as: 'step',
|
||||||
|
attributes: ['name', 'step_order'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'requested_by_user',
|
||||||
|
attributes: ['firstName', 'lastName', 'email'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'assigned_to_user',
|
||||||
|
attributes: ['firstName', 'lastName', 'email'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['requested_at', 'ASC']],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
db.requisitions.findAll({
|
||||||
|
attributes: [
|
||||||
|
'id',
|
||||||
|
'requisition_number',
|
||||||
|
'title',
|
||||||
|
'procurement_method',
|
||||||
|
'estimated_amount',
|
||||||
|
'currency',
|
||||||
|
'needed_by_date',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['submitted', 'under_review', 'approved', 'in_tender', 'awarded'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.provinces,
|
||||||
|
as: 'province',
|
||||||
|
attributes: ['name', 'code'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
db.compliance_alerts.findAll({
|
||||||
|
attributes: ['id', 'alert_type', 'severity', 'title', 'details', 'record_type', 'record_key', 'due_at', 'status'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['open', 'acknowledged'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'assigned_to_user',
|
||||||
|
attributes: ['firstName', 'lastName', 'email'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
['severity', 'DESC'],
|
||||||
|
['due_at', 'ASC'],
|
||||||
|
['createdAt', 'DESC'],
|
||||||
|
],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
db.projects.findAll({
|
||||||
|
attributes: ['id', 'status', 'completion_percent'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.provinces,
|
||||||
|
as: 'province',
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
db.notifications.findAll({
|
||||||
|
attributes: ['id', 'type', 'title', 'message', 'read', 'sent_at', 'record_type', 'record_key'],
|
||||||
|
where: {
|
||||||
|
...scopeByOrganization(currentUser),
|
||||||
|
},
|
||||||
|
order: [['sent_at', 'DESC'], ['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const provinceRolloutMap = rolloutProjects.reduce((accumulator, project) => {
|
||||||
|
const provinceName = project.province?.name || 'Unassigned province';
|
||||||
|
|
||||||
|
if (!accumulator[provinceName]) {
|
||||||
|
accumulator[provinceName] = {
|
||||||
|
provinceName,
|
||||||
|
totalProjects: 0,
|
||||||
|
activeProjects: 0,
|
||||||
|
completionTotal: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator[provinceName].totalProjects += 1;
|
||||||
|
accumulator[provinceName].completionTotal += toNumber(project.completion_percent);
|
||||||
|
|
||||||
|
if (project.status === 'active') {
|
||||||
|
accumulator[provinceName].activeProjects += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const provinceRollout = Object.values(provinceRolloutMap)
|
||||||
|
.map((province) => ({
|
||||||
|
provinceName: province.provinceName,
|
||||||
|
totalProjects: province.totalProjects,
|
||||||
|
activeProjects: province.activeProjects,
|
||||||
|
averageCompletion: province.totalProjects
|
||||||
|
? Math.round(province.completionTotal / province.totalProjects)
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.totalProjects - left.totalProjects)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const formattedApprovalQueue = approvalQueue.map((approval) => ({
|
||||||
|
id: approval.id,
|
||||||
|
recordType: approval.record_type,
|
||||||
|
recordKey: approval.record_key,
|
||||||
|
status: approval.status,
|
||||||
|
requestedAt: approval.requested_at,
|
||||||
|
workflowName: approval.workflow?.name || 'Workflow not assigned',
|
||||||
|
stepName: approval.step?.name || 'No active step',
|
||||||
|
stepOrder: approval.step?.step_order || null,
|
||||||
|
requestedBy: approval.requested_by_user
|
||||||
|
? `${approval.requested_by_user.firstName || ''} ${approval.requested_by_user.lastName || ''}`.trim() || approval.requested_by_user.email
|
||||||
|
: 'Unknown requester',
|
||||||
|
assignedTo: approval.assigned_to_user
|
||||||
|
? `${approval.assigned_to_user.firstName || ''} ${approval.assigned_to_user.lastName || ''}`.trim() || approval.assigned_to_user.email
|
||||||
|
: 'Unassigned',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedProcurementQueue = procurementQueue.map((requisition) => ({
|
||||||
|
id: requisition.id,
|
||||||
|
requisitionNumber: requisition.requisition_number,
|
||||||
|
title: requisition.title,
|
||||||
|
procurementMethod: requisition.procurement_method,
|
||||||
|
estimatedAmount: toNumber(requisition.estimated_amount),
|
||||||
|
currency: requisition.currency,
|
||||||
|
neededByDate: requisition.needed_by_date,
|
||||||
|
status: requisition.status,
|
||||||
|
provinceName: requisition.province?.name || 'Unassigned province',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedContractWatchlist = contractWatchlist.map((contract) => ({
|
||||||
|
id: contract.id,
|
||||||
|
contractNumber: contract.contract_number,
|
||||||
|
title: contract.title,
|
||||||
|
contractValue: toNumber(contract.contract_value),
|
||||||
|
currency: contract.currency,
|
||||||
|
endDate: contract.end_date,
|
||||||
|
status: contract.status,
|
||||||
|
vendorName: contract.vendor?.name || 'Vendor not linked',
|
||||||
|
projectName: contract.project?.name || 'Project not linked',
|
||||||
|
daysToExpiry: contract.end_date
|
||||||
|
? Math.ceil((new Date(contract.end_date).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedTopContracts = topContracts.map((contract) => ({
|
||||||
|
id: contract.id,
|
||||||
|
contractNumber: contract.contract_number,
|
||||||
|
title: contract.title,
|
||||||
|
contractValue: toNumber(contract.contract_value),
|
||||||
|
currency: contract.currency,
|
||||||
|
endDate: contract.end_date,
|
||||||
|
status: contract.status,
|
||||||
|
vendorName: contract.vendor?.name || 'Vendor not linked',
|
||||||
|
projectName: contract.project?.name || 'Project not linked',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedRiskPanel = riskPanel.map((alert) => ({
|
||||||
|
id: alert.id,
|
||||||
|
alertType: alert.alert_type,
|
||||||
|
severity: alert.severity,
|
||||||
|
title: alert.title,
|
||||||
|
details: alert.details,
|
||||||
|
recordType: alert.record_type,
|
||||||
|
recordKey: alert.record_key,
|
||||||
|
dueAt: alert.due_at,
|
||||||
|
status: alert.status,
|
||||||
|
assignedTo: alert.assigned_to_user
|
||||||
|
? `${alert.assigned_to_user.firstName || ''} ${alert.assigned_to_user.lastName || ''}`.trim() || alert.assigned_to_user.email
|
||||||
|
: 'Unassigned',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedNotifications = recentNotifications.map((notification) => ({
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
read: notification.read,
|
||||||
|
sentAt: notification.sent_at,
|
||||||
|
recordType: notification.record_type,
|
||||||
|
recordKey: notification.record_key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
approvedBudget: {
|
||||||
|
USD: approvedBudgetUSD,
|
||||||
|
CDF: approvedBudgetCDF,
|
||||||
|
},
|
||||||
|
committedBudget: {
|
||||||
|
USD: committedBudgetUSD,
|
||||||
|
CDF: committedBudgetCDF,
|
||||||
|
},
|
||||||
|
disbursedBudget: {
|
||||||
|
USD: disbursedBudgetUSD,
|
||||||
|
CDF: disbursedBudgetCDF,
|
||||||
|
},
|
||||||
|
budgetVariance: {
|
||||||
|
USD: approvedBudgetUSD - committedBudgetUSD,
|
||||||
|
CDF: approvedBudgetCDF - committedBudgetCDF,
|
||||||
|
},
|
||||||
|
activeProjects,
|
||||||
|
procurementPipeline,
|
||||||
|
pendingApprovals,
|
||||||
|
contractsNearingExpiry,
|
||||||
|
overduePayments,
|
||||||
|
vendorComplianceAlerts,
|
||||||
|
openRiskAlerts,
|
||||||
|
unreadNotifications,
|
||||||
|
averageProjectProgress: Math.round(toNumber(averageProjectProgress) || 0),
|
||||||
|
highRiskProjects,
|
||||||
|
};
|
||||||
|
|
||||||
|
const datasets = {
|
||||||
|
approvalQueue: formattedApprovalQueue,
|
||||||
|
procurementQueue: formattedProcurementQueue,
|
||||||
|
contractWatchlist: formattedContractWatchlist,
|
||||||
|
topContracts: formattedTopContracts,
|
||||||
|
riskPanel: formattedRiskPanel,
|
||||||
|
provinceRollout,
|
||||||
|
recentNotifications: formattedNotifications,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
workspace: buildWorkspacePayload(currentUser?.app_role?.name, summary, datasets),
|
||||||
|
summary,
|
||||||
|
...datasets,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import AsideMenuItem from './AsideMenuItem'
|
import AsideMenuItem from './AsideMenuItem'
|
||||||
import {useAppSelector} from "../stores/hooks";
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
import { getWorkspaceConfig, itemVisibleForRole } from '../helpers/workspace';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[]
|
||||||
@ -15,16 +16,28 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
|||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
const roleName = currentUser?.app_role?.name;
|
||||||
|
const workspaceConfig = getWorkspaceConfig(roleName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={className}>
|
<ul className={className}>
|
||||||
{menu.map((item, index) => {
|
{menu.map((item, index) => {
|
||||||
|
if (!itemVisibleForRole(item.roles, roleName)) return null;
|
||||||
|
if (!hasPermission(currentUser, item.permissions)) return null;
|
||||||
|
|
||||||
if (!hasPermission(currentUser, item.permissions)) return null;
|
const displayItem: MenuAsideItem = {
|
||||||
|
...item,
|
||||||
|
label: item.labelByRole?.[roleName] || item.label,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (displayItem.label === 'Role Workspace') {
|
||||||
|
displayItem.label = workspaceConfig.sidebarLabel;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<AsideMenuItem
|
<AsideMenuItem
|
||||||
item={item}
|
item={displayItem}
|
||||||
isDropdownList={isDropdownList}
|
isDropdownList={isDropdownList}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
629
frontend/src/helpers/workspace.ts
Normal file
629
frontend/src/helpers/workspace.ts
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
export const WORKSPACE_ROLES = {
|
||||||
|
superAdmin: 'Super Administrator',
|
||||||
|
administrator: 'Administrator',
|
||||||
|
directorGeneral: 'Director General',
|
||||||
|
financeDirector: 'Finance Director',
|
||||||
|
procurementLead: 'Procurement Lead',
|
||||||
|
complianceAuditLead: 'Compliance and Audit Lead',
|
||||||
|
projectDeliveryLead: 'Project Delivery Lead',
|
||||||
|
public: 'Public',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type WorkspaceMetricKey =
|
||||||
|
| 'approvedBudget'
|
||||||
|
| 'committedBudget'
|
||||||
|
| 'disbursedBudget'
|
||||||
|
| 'pendingApprovals'
|
||||||
|
| 'contractsNearingExpiry'
|
||||||
|
| 'vendorComplianceAlerts'
|
||||||
|
| 'procurementPipeline'
|
||||||
|
| 'openRiskAlerts'
|
||||||
|
| 'averageProjectProgress'
|
||||||
|
| 'highRiskProjects'
|
||||||
|
| 'overduePayments'
|
||||||
|
| 'activeProjects'
|
||||||
|
| 'unreadNotifications';
|
||||||
|
|
||||||
|
export type WorkspaceDetailBlockKey =
|
||||||
|
| 'focus'
|
||||||
|
| 'summary'
|
||||||
|
| 'watchlist'
|
||||||
|
| 'approvalRisk'
|
||||||
|
| 'operations'
|
||||||
|
| 'delivery'
|
||||||
|
| 'actions';
|
||||||
|
|
||||||
|
export type WorkspaceSectionKey =
|
||||||
|
| 'approvalQueue'
|
||||||
|
| 'riskPanel'
|
||||||
|
| 'procurementQueue'
|
||||||
|
| 'contractWatchlist'
|
||||||
|
| 'recentNotifications'
|
||||||
|
| 'provinceRollout'
|
||||||
|
| 'topContracts'
|
||||||
|
| 'quickActions';
|
||||||
|
|
||||||
|
export type WorkspaceQuickLinkIconKey =
|
||||||
|
| 'organizations'
|
||||||
|
| 'users'
|
||||||
|
| 'approvals'
|
||||||
|
| 'notifications'
|
||||||
|
| 'projects'
|
||||||
|
| 'contracts'
|
||||||
|
| 'payments'
|
||||||
|
| 'allocations'
|
||||||
|
| 'requisitions'
|
||||||
|
| 'tenders'
|
||||||
|
| 'compliance'
|
||||||
|
| 'audit'
|
||||||
|
| 'milestones'
|
||||||
|
| 'vendors';
|
||||||
|
|
||||||
|
export interface WorkspaceAction {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceQuickLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: WorkspaceQuickLinkIconKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSectionCopy {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceConfig {
|
||||||
|
sidebarLabel: string;
|
||||||
|
pageTitle: string;
|
||||||
|
eyebrow: string;
|
||||||
|
heroTitle: string;
|
||||||
|
heroDescription: string;
|
||||||
|
primaryAction: WorkspaceAction;
|
||||||
|
secondaryAction: WorkspaceAction;
|
||||||
|
highlightedMetricKeys: WorkspaceMetricKey[];
|
||||||
|
heroMetricKeys: WorkspaceMetricKey[];
|
||||||
|
blockOrder: WorkspaceDetailBlockKey[];
|
||||||
|
sectionCopy: Record<WorkspaceSectionKey, WorkspaceSectionCopy>;
|
||||||
|
quickLinks: WorkspaceQuickLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceConfigInput = Omit<WorkspaceConfig, 'sectionCopy' | 'heroMetricKeys' | 'blockOrder' | 'quickLinks'> & {
|
||||||
|
heroMetricKeys?: WorkspaceMetricKey[];
|
||||||
|
blockOrder?: WorkspaceDetailBlockKey[];
|
||||||
|
sectionCopy?: Partial<Record<WorkspaceSectionKey, WorkspaceSectionCopy>>;
|
||||||
|
quickLinks?: WorkspaceQuickLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultBlockOrder: WorkspaceDetailBlockKey[] = [
|
||||||
|
'focus',
|
||||||
|
'summary',
|
||||||
|
'watchlist',
|
||||||
|
'approvalRisk',
|
||||||
|
'operations',
|
||||||
|
'delivery',
|
||||||
|
'actions',
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultSectionCopy: Record<WorkspaceSectionKey, WorkspaceSectionCopy> = {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Approval inbox',
|
||||||
|
title: 'Pending institutional decisions',
|
||||||
|
actionLabel: 'Open approvals',
|
||||||
|
},
|
||||||
|
riskPanel: {
|
||||||
|
eyebrow: 'Risk and red-flag panel',
|
||||||
|
title: 'Compliance, audit, and payment exposure',
|
||||||
|
},
|
||||||
|
procurementQueue: {
|
||||||
|
eyebrow: 'Procurement workflow',
|
||||||
|
title: 'Live procurement pipeline',
|
||||||
|
actionLabel: 'All requisitions',
|
||||||
|
},
|
||||||
|
contractWatchlist: {
|
||||||
|
eyebrow: 'Contract watchlist',
|
||||||
|
title: 'Expiring commitments',
|
||||||
|
actionLabel: 'Contract register',
|
||||||
|
},
|
||||||
|
recentNotifications: {
|
||||||
|
eyebrow: 'Notifications',
|
||||||
|
title: 'Recent operational signals',
|
||||||
|
actionLabel: 'Notification center',
|
||||||
|
},
|
||||||
|
provinceRollout: {
|
||||||
|
eyebrow: 'Geographic rollout',
|
||||||
|
title: 'Projects by province',
|
||||||
|
actionLabel: 'Projects register',
|
||||||
|
},
|
||||||
|
topContracts: {
|
||||||
|
eyebrow: 'Top-value contracts',
|
||||||
|
title: 'Largest commitments currently tracked',
|
||||||
|
actionLabel: 'Vendor master',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Action launcher',
|
||||||
|
title: 'Move from oversight to execution',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultQuickLinks: WorkspaceQuickLink[] = [
|
||||||
|
{
|
||||||
|
href: '/requisitions/requisitions-new',
|
||||||
|
label: 'Create requisition',
|
||||||
|
description: 'Start the procurement workflow.',
|
||||||
|
icon: 'requisitions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Approval inbox',
|
||||||
|
description: 'Review pending decisions and escalations.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Contract register',
|
||||||
|
description: 'Inspect value, milestones, and expiry status.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/audit_logs/audit_logs-list',
|
||||||
|
label: 'Audit center',
|
||||||
|
description: 'Trace actions, timestamps, and record history.',
|
||||||
|
icon: 'audit',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const createWorkspaceConfig = ({
|
||||||
|
heroMetricKeys,
|
||||||
|
blockOrder,
|
||||||
|
sectionCopy,
|
||||||
|
quickLinks,
|
||||||
|
...config
|
||||||
|
}: WorkspaceConfigInput): WorkspaceConfig => ({
|
||||||
|
...config,
|
||||||
|
heroMetricKeys: heroMetricKeys || config.highlightedMetricKeys.slice(0, 4),
|
||||||
|
blockOrder: blockOrder || defaultBlockOrder,
|
||||||
|
sectionCopy: {
|
||||||
|
...defaultSectionCopy,
|
||||||
|
...sectionCopy,
|
||||||
|
},
|
||||||
|
quickLinks: quickLinks || defaultQuickLinks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceConfigs: Record<string, WorkspaceConfig> = {
|
||||||
|
[WORKSPACE_ROLES.superAdmin]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Platform Command',
|
||||||
|
pageTitle: 'Super Administrator Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Super Administrator workspace',
|
||||||
|
heroTitle: 'Oversee tenants, access control, institutional activity, and platform-level risk from one command surface.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace prioritizes cross-organization visibility, access governance, auditability, and system stewardship while keeping drill-down access into the same ERP records used by operating teams.',
|
||||||
|
primaryAction: { href: '/organizations/organizations-list', label: 'Review organizations' },
|
||||||
|
secondaryAction: { href: '/users/users-list', label: 'Manage users' },
|
||||||
|
highlightedMetricKeys: ['activeProjects', 'pendingApprovals', 'openRiskAlerts', 'contractsNearingExpiry', 'unreadNotifications', 'vendorComplianceAlerts'],
|
||||||
|
heroMetricKeys: ['activeProjects', 'pendingApprovals', 'openRiskAlerts', 'unreadNotifications'],
|
||||||
|
blockOrder: ['focus', 'watchlist', 'summary', 'approvalRisk', 'operations', 'delivery', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Governance queue',
|
||||||
|
title: 'Approvals and escalations needing platform attention',
|
||||||
|
actionLabel: 'Open governance queue',
|
||||||
|
},
|
||||||
|
riskPanel: {
|
||||||
|
eyebrow: 'Cross-cutting control flags',
|
||||||
|
title: 'Platform-wide exceptions and institutional exposure',
|
||||||
|
},
|
||||||
|
recentNotifications: {
|
||||||
|
eyebrow: 'Platform activity',
|
||||||
|
title: 'Recent operational signals across tenants',
|
||||||
|
actionLabel: 'Open activity feed',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Platform actions',
|
||||||
|
title: 'Jump directly into oversight and access-control work',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/organizations/organizations-list',
|
||||||
|
label: 'Organization registry',
|
||||||
|
description: 'Review tenant setup and coverage.',
|
||||||
|
icon: 'organizations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'User access',
|
||||||
|
description: 'Manage accounts, roles, and ownership.',
|
||||||
|
icon: 'users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Escalation queue',
|
||||||
|
description: 'Follow stalled approvals and control breaks.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/audit_logs/audit_logs-list',
|
||||||
|
label: 'Audit trail',
|
||||||
|
description: 'Trace administrative and ERP record changes.',
|
||||||
|
icon: 'audit',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.administrator]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Operations Command',
|
||||||
|
pageTitle: 'Administrator Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Administrator workspace',
|
||||||
|
heroTitle: 'Run tenant operations, workflow readiness, user support, and master data hygiene without losing ERP traceability.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace is tuned for organization-level administration: approval setup, departments, notifications, operational bottlenecks, and administrative follow-through across the shared ERP.',
|
||||||
|
primaryAction: { href: '/approval_workflows/approval_workflows-list', label: 'Review workflows' },
|
||||||
|
secondaryAction: { href: '/notifications/notifications-list', label: 'Check notifications' },
|
||||||
|
highlightedMetricKeys: ['pendingApprovals', 'unreadNotifications', 'contractsNearingExpiry', 'openRiskAlerts', 'activeProjects', 'procurementPipeline'],
|
||||||
|
heroMetricKeys: ['pendingApprovals', 'unreadNotifications', 'procurementPipeline', 'contractsNearingExpiry'],
|
||||||
|
blockOrder: ['focus', 'summary', 'approvalRisk', 'watchlist', 'operations', 'delivery', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Operations queue',
|
||||||
|
title: 'Approvals waiting on workflow administration',
|
||||||
|
actionLabel: 'Open workflow queue',
|
||||||
|
},
|
||||||
|
procurementQueue: {
|
||||||
|
eyebrow: 'Operational throughput',
|
||||||
|
title: 'Requisitions that may require admin unblock',
|
||||||
|
actionLabel: 'Open requisitions',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Administrative actions',
|
||||||
|
title: 'Go straight to configuration and operational follow-through',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/approval_workflows/approval_workflows-list',
|
||||||
|
label: 'Workflow setup',
|
||||||
|
description: 'Review approval design and routing.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
label: 'Notification center',
|
||||||
|
description: 'Resolve unread system and user signals.',
|
||||||
|
icon: 'notifications',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'User support',
|
||||||
|
description: 'Update access and account records.',
|
||||||
|
icon: 'users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
label: 'Project register',
|
||||||
|
description: 'Verify record coverage and ownership.',
|
||||||
|
icon: 'projects',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.directorGeneral]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Executive Command',
|
||||||
|
pageTitle: 'Director General Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Director General workspace',
|
||||||
|
heroTitle: 'Track strategic budget use, delivery momentum, risk concentration, and contract exposure across the institution.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace emphasizes executive oversight over budgets, projects, approvals, and risk hot spots while staying connected to the underlying contracts, requisitions, and implementation records.',
|
||||||
|
primaryAction: { href: '/projects/projects-list', label: 'Review projects' },
|
||||||
|
secondaryAction: { href: '/contracts/contracts-list', label: 'Review contracts' },
|
||||||
|
highlightedMetricKeys: ['approvedBudget', 'committedBudget', 'disbursedBudget', 'pendingApprovals', 'highRiskProjects', 'contractsNearingExpiry'],
|
||||||
|
heroMetricKeys: ['approvedBudget', 'disbursedBudget', 'pendingApprovals', 'highRiskProjects'],
|
||||||
|
blockOrder: ['summary', 'focus', 'watchlist', 'delivery', 'approvalRisk', 'operations', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
riskPanel: {
|
||||||
|
eyebrow: 'Executive risk watch',
|
||||||
|
title: 'Strategic risks, bottlenecks, and control exposure',
|
||||||
|
},
|
||||||
|
provinceRollout: {
|
||||||
|
eyebrow: 'Delivery coverage',
|
||||||
|
title: 'Provincial rollout and execution pace',
|
||||||
|
actionLabel: 'Open projects',
|
||||||
|
},
|
||||||
|
topContracts: {
|
||||||
|
eyebrow: 'Contract exposure',
|
||||||
|
title: 'Largest commitments shaping institutional delivery',
|
||||||
|
actionLabel: 'Vendor and contract view',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Executive actions',
|
||||||
|
title: 'Jump into the records most relevant for strategic follow-up',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
label: 'Project portfolio',
|
||||||
|
description: 'Review delivery status and ownership.',
|
||||||
|
icon: 'projects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Contract exposure',
|
||||||
|
description: 'Inspect major commitments and expiry dates.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Decision queue',
|
||||||
|
description: 'Track items waiting for institutional action.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
label: 'Executive signals',
|
||||||
|
description: 'Review the latest alerts and escalations.',
|
||||||
|
icon: 'notifications',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.financeDirector]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Financial Control',
|
||||||
|
pageTitle: 'Finance Director Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Finance Director workspace',
|
||||||
|
heroTitle: 'Monitor allocations, commitments, invoices, payments, and approval pressure with finance-first operational context.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace focuses on fiscal control across allocations, commitments, disbursements, overdue payment requests, and approvals while remaining linked to the procurement and project records driving spend.',
|
||||||
|
primaryAction: { href: '/payment_requests/payment_requests-list', label: 'Review payment requests' },
|
||||||
|
secondaryAction: { href: '/allocations/allocations-list', label: 'Review allocations' },
|
||||||
|
highlightedMetricKeys: ['approvedBudget', 'committedBudget', 'disbursedBudget', 'overduePayments', 'pendingApprovals', 'unreadNotifications'],
|
||||||
|
heroMetricKeys: ['approvedBudget', 'committedBudget', 'disbursedBudget', 'overduePayments'],
|
||||||
|
blockOrder: ['summary', 'watchlist', 'focus', 'approvalRisk', 'delivery', 'operations', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Finance queue',
|
||||||
|
title: 'Approvals affecting commitments and disbursements',
|
||||||
|
actionLabel: 'Open finance approvals',
|
||||||
|
},
|
||||||
|
riskPanel: {
|
||||||
|
eyebrow: 'Fiscal control panel',
|
||||||
|
title: 'Budget pressure, overdue payments, and control exceptions',
|
||||||
|
},
|
||||||
|
topContracts: {
|
||||||
|
eyebrow: 'High-value commitments',
|
||||||
|
title: 'Largest contracts influencing current financial exposure',
|
||||||
|
actionLabel: 'Open vendor master',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Finance actions',
|
||||||
|
title: 'Move from budget control into payment and allocation follow-up',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/payment_requests/payment_requests-list',
|
||||||
|
label: 'Payment requests',
|
||||||
|
description: 'Review overdue and pending disbursements.',
|
||||||
|
icon: 'payments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/allocations/allocations-list',
|
||||||
|
label: 'Allocations',
|
||||||
|
description: 'Check available headroom and funding coverage.',
|
||||||
|
icon: 'allocations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Commitment register',
|
||||||
|
description: 'Inspect the contracts driving spend.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Approval pressure',
|
||||||
|
description: 'Track bottlenecks holding back finance action.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.procurementLead]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Procurement Desk',
|
||||||
|
pageTitle: 'Procurement Lead Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Procurement Lead workspace',
|
||||||
|
heroTitle: 'Stay on top of requisitions, tender flow, contract deadlines, and approval backlog across the procurement chain.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace keeps procurement leadership focused on live demand, tender progression, expiring contracts, and workflow bottlenecks while preserving drill-down into vendors, awards, and originating requisitions.',
|
||||||
|
primaryAction: { href: '/requisitions/requisitions-list', label: 'Review requisitions' },
|
||||||
|
secondaryAction: { href: '/tenders/tenders-list', label: 'Open tender pipeline' },
|
||||||
|
highlightedMetricKeys: ['procurementPipeline', 'pendingApprovals', 'contractsNearingExpiry', 'vendorComplianceAlerts', 'openRiskAlerts', 'activeProjects'],
|
||||||
|
heroMetricKeys: ['procurementPipeline', 'pendingApprovals', 'contractsNearingExpiry', 'vendorComplianceAlerts'],
|
||||||
|
blockOrder: ['focus', 'summary', 'operations', 'watchlist', 'approvalRisk', 'delivery', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Procurement approvals',
|
||||||
|
title: 'Decisions blocking sourcing and award progress',
|
||||||
|
actionLabel: 'Open approval backlog',
|
||||||
|
},
|
||||||
|
procurementQueue: {
|
||||||
|
eyebrow: 'Sourcing pipeline',
|
||||||
|
title: 'Requisitions and tenders requiring procurement action',
|
||||||
|
actionLabel: 'Open sourcing queue',
|
||||||
|
},
|
||||||
|
contractWatchlist: {
|
||||||
|
eyebrow: 'Contract renewals',
|
||||||
|
title: 'Supplier commitments nearing expiry or follow-up',
|
||||||
|
actionLabel: 'Open contracts',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Procurement actions',
|
||||||
|
title: 'Jump into the next sourcing and contract-management tasks',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
label: 'Requisition queue',
|
||||||
|
description: 'Review demand and readiness to source.',
|
||||||
|
icon: 'requisitions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/tenders/tenders-list',
|
||||||
|
label: 'Tender pipeline',
|
||||||
|
description: 'Follow active tenders and consultations.',
|
||||||
|
icon: 'tenders',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Contract deadlines',
|
||||||
|
description: 'Track expiring commitments and supplier action.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/vendors/vendors-list',
|
||||||
|
label: 'Vendor register',
|
||||||
|
description: 'Inspect supplier readiness and history.',
|
||||||
|
icon: 'vendors',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.complianceAuditLead]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Compliance Desk',
|
||||||
|
pageTitle: 'Compliance & Audit Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Compliance & Audit workspace',
|
||||||
|
heroTitle: 'Surface red flags, document gaps, control exceptions, and approval exposure before they become institutional failures.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace is optimized for compliance and audit follow-up: open alerts, expiring obligations, evidence gaps, approvals awaiting control review, and links into the exact underlying ERP records.',
|
||||||
|
primaryAction: { href: '/compliance_alerts/compliance_alerts-list', label: 'Review compliance alerts' },
|
||||||
|
secondaryAction: { href: '/audit_logs/audit_logs-list', label: 'Open audit logs' },
|
||||||
|
highlightedMetricKeys: ['openRiskAlerts', 'vendorComplianceAlerts', 'contractsNearingExpiry', 'pendingApprovals', 'overduePayments', 'unreadNotifications'],
|
||||||
|
heroMetricKeys: ['openRiskAlerts', 'vendorComplianceAlerts', 'contractsNearingExpiry', 'pendingApprovals'],
|
||||||
|
blockOrder: ['watchlist', 'focus', 'summary', 'approvalRisk', 'operations', 'delivery', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Control review queue',
|
||||||
|
title: 'Approvals and cases requiring audit follow-through',
|
||||||
|
actionLabel: 'Open control queue',
|
||||||
|
},
|
||||||
|
riskPanel: {
|
||||||
|
eyebrow: 'Compliance watch',
|
||||||
|
title: 'Exceptions, overdue items, and evidence gaps',
|
||||||
|
},
|
||||||
|
recentNotifications: {
|
||||||
|
eyebrow: 'Alert traffic',
|
||||||
|
title: 'Recent system signals and policy-relevant activity',
|
||||||
|
actionLabel: 'Open alert feed',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Audit actions',
|
||||||
|
title: 'Go straight to alerts, logs, and records needing review',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
label: 'Compliance alerts',
|
||||||
|
description: 'Review open red flags and follow-up status.',
|
||||||
|
icon: 'compliance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/audit_logs/audit_logs-list',
|
||||||
|
label: 'Audit logs',
|
||||||
|
description: 'Trace evidence across users and records.',
|
||||||
|
icon: 'audit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Control queue',
|
||||||
|
description: 'Inspect approvals needing governance review.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Contract obligations',
|
||||||
|
description: 'Check commitments nearing expiry or breach.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[WORKSPACE_ROLES.projectDeliveryLead]: createWorkspaceConfig({
|
||||||
|
sidebarLabel: 'Delivery Command',
|
||||||
|
pageTitle: 'Project Delivery Workspace',
|
||||||
|
eyebrow: 'FDSU ERP · Project Delivery workspace',
|
||||||
|
heroTitle: 'Keep execution moving by watching project progress, blocked approvals, payment lag, and field-level delivery risk.',
|
||||||
|
heroDescription:
|
||||||
|
'This workspace centers on implementation performance: active projects, milestone progress, approvals affecting execution, and risks that can delay delivery across provinces and programs.',
|
||||||
|
primaryAction: { href: '/projects/projects-list', label: 'Review projects' },
|
||||||
|
secondaryAction: { href: '/project_milestones/project_milestones-list', label: 'Review milestones' },
|
||||||
|
highlightedMetricKeys: ['activeProjects', 'averageProjectProgress', 'highRiskProjects', 'pendingApprovals', 'overduePayments', 'unreadNotifications'],
|
||||||
|
heroMetricKeys: ['activeProjects', 'averageProjectProgress', 'highRiskProjects', 'pendingApprovals'],
|
||||||
|
blockOrder: ['summary', 'focus', 'delivery', 'approvalRisk', 'watchlist', 'operations', 'actions'],
|
||||||
|
sectionCopy: {
|
||||||
|
approvalQueue: {
|
||||||
|
eyebrow: 'Execution blockers',
|
||||||
|
title: 'Approvals holding back project delivery',
|
||||||
|
actionLabel: 'Open delivery blockers',
|
||||||
|
},
|
||||||
|
provinceRollout: {
|
||||||
|
eyebrow: 'Delivery heatmap',
|
||||||
|
title: 'Progress by province and execution footprint',
|
||||||
|
actionLabel: 'Open project register',
|
||||||
|
},
|
||||||
|
topContracts: {
|
||||||
|
eyebrow: 'Major delivery contracts',
|
||||||
|
title: 'Largest commitments underpinning project execution',
|
||||||
|
actionLabel: 'Open suppliers',
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
eyebrow: 'Delivery actions',
|
||||||
|
title: 'Move into the projects, milestones, and blockers that need attention',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
label: 'Project portfolio',
|
||||||
|
description: 'Review implementation status and ownership.',
|
||||||
|
icon: 'projects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/project_milestones/project_milestones-list',
|
||||||
|
label: 'Milestones',
|
||||||
|
description: 'Track critical delivery checkpoints.',
|
||||||
|
icon: 'milestones',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approvals/approvals-list',
|
||||||
|
label: 'Delivery blockers',
|
||||||
|
description: 'See approvals slowing field execution.',
|
||||||
|
icon: 'approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Supporting contracts',
|
||||||
|
description: 'Inspect contracts affecting delivery scope.',
|
||||||
|
icon: 'contracts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWorkspaceConfig(roleName?: string | null): WorkspaceConfig {
|
||||||
|
if (!roleName) {
|
||||||
|
return workspaceConfigs[WORKSPACE_ROLES.projectDeliveryLead];
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceConfigs[roleName] || workspaceConfigs[WORKSPACE_ROLES.projectDeliveryLead];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemVisibleForRole(itemRoles?: string[], roleName?: string | null) {
|
||||||
|
if (!itemRoles?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roleName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemRoles.includes(roleName);
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ export type MenuAsideItem = {
|
|||||||
withDevider?: boolean;
|
withDevider?: boolean;
|
||||||
menu?: MenuAsideItem[]
|
menu?: MenuAsideItem[]
|
||||||
permissions?: string | string[]
|
permissions?: string | string[]
|
||||||
|
roles?: string[]
|
||||||
|
labelByRole?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuNavBarItem = {
|
export type MenuNavBarItem = {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -1,11 +1,132 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
import { WORKSPACE_ROLES } from './helpers/workspace'
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
|
{
|
||||||
|
href: '/executive-summary',
|
||||||
|
icon: icon.mdiBankOutline,
|
||||||
|
label: 'Role Workspace',
|
||||||
|
labelByRole: {
|
||||||
|
[WORKSPACE_ROLES.superAdmin]: 'Platform Command',
|
||||||
|
[WORKSPACE_ROLES.administrator]: 'Operations Command',
|
||||||
|
[WORKSPACE_ROLES.directorGeneral]: 'Executive Command',
|
||||||
|
[WORKSPACE_ROLES.financeDirector]: 'Financial Control',
|
||||||
|
[WORKSPACE_ROLES.procurementLead]: 'Procurement Desk',
|
||||||
|
[WORKSPACE_ROLES.complianceAuditLead]: 'Compliance Desk',
|
||||||
|
[WORKSPACE_ROLES.projectDeliveryLead]: 'Delivery Command',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Platform Widgets',
|
||||||
|
roles: [WORKSPACE_ROLES.superAdmin, WORKSPACE_ROLES.administrator],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/organizations/organizations-list',
|
||||||
|
label: 'Tenant Oversight',
|
||||||
|
icon: icon.mdiDomain,
|
||||||
|
permissions: 'READ_ORGANIZATIONS',
|
||||||
|
roles: [WORKSPACE_ROLES.superAdmin],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'Access Control',
|
||||||
|
icon: icon.mdiAccountGroup,
|
||||||
|
permissions: 'READ_USERS',
|
||||||
|
roles: [WORKSPACE_ROLES.superAdmin],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/approval_workflows/approval_workflows-list',
|
||||||
|
label: 'Workflow Readiness',
|
||||||
|
icon: icon.mdiSitemap,
|
||||||
|
permissions: 'READ_APPROVAL_WORKFLOWS',
|
||||||
|
roles: [WORKSPACE_ROLES.administrator],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/notifications/notifications-list',
|
||||||
|
label: 'Operational Notices',
|
||||||
|
icon: icon.mdiBellOutline,
|
||||||
|
permissions: 'READ_NOTIFICATIONS',
|
||||||
|
roles: [WORKSPACE_ROLES.administrator],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
label: 'Strategic Portfolio',
|
||||||
|
icon: icon.mdiChartTimelineVariant,
|
||||||
|
permissions: 'READ_PROJECTS',
|
||||||
|
roles: [WORKSPACE_ROLES.directorGeneral],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contracts/contracts-list',
|
||||||
|
label: 'Contract Exposure',
|
||||||
|
icon: icon.mdiFileDocumentOutline,
|
||||||
|
permissions: 'READ_CONTRACTS',
|
||||||
|
roles: [WORKSPACE_ROLES.directorGeneral],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/payment_requests/payment_requests-list',
|
||||||
|
label: 'Payment Control',
|
||||||
|
icon: icon.mdiCashCheck,
|
||||||
|
permissions: 'READ_PAYMENT_REQUESTS',
|
||||||
|
roles: [WORKSPACE_ROLES.financeDirector],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/allocations/allocations-list',
|
||||||
|
label: 'Allocation Register',
|
||||||
|
icon: icon.mdiWalletOutline,
|
||||||
|
permissions: 'READ_ALLOCATIONS',
|
||||||
|
roles: [WORKSPACE_ROLES.financeDirector],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/requisitions/requisitions-list',
|
||||||
|
label: 'Requisition Queue',
|
||||||
|
icon: icon.mdiClipboardListOutline,
|
||||||
|
permissions: 'READ_REQUISITIONS',
|
||||||
|
roles: [WORKSPACE_ROLES.procurementLead],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/tenders/tenders-list',
|
||||||
|
label: 'Tender Pipeline',
|
||||||
|
icon: icon.mdiGavel,
|
||||||
|
permissions: 'READ_TENDERS',
|
||||||
|
roles: [WORKSPACE_ROLES.procurementLead],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
|
label: 'Control Exceptions',
|
||||||
|
icon: icon.mdiShieldAlertOutline,
|
||||||
|
permissions: 'READ_COMPLIANCE_ALERTS',
|
||||||
|
roles: [WORKSPACE_ROLES.complianceAuditLead],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/audit_logs/audit_logs-list',
|
||||||
|
label: 'Audit Trail',
|
||||||
|
icon: icon.mdiClipboardTextClock,
|
||||||
|
permissions: 'READ_AUDIT_LOGS',
|
||||||
|
roles: [WORKSPACE_ROLES.complianceAuditLead],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
label: 'Delivery Portfolio',
|
||||||
|
icon: icon.mdiChartTimelineVariant,
|
||||||
|
permissions: 'READ_PROJECTS',
|
||||||
|
roles: [WORKSPACE_ROLES.projectDeliveryLead],
|
||||||
|
withDevider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/project_milestones/project_milestones-list',
|
||||||
|
label: 'Milestone Tracker',
|
||||||
|
icon: icon.mdiFlagCheckered,
|
||||||
|
permissions: 'READ_PROJECT_MILESTONES',
|
||||||
|
roles: [WORKSPACE_ROLES.projectDeliveryLead],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
997
frontend/src/pages/executive-summary.tsx
Normal file
997
frontend/src/pages/executive-summary.tsx
Normal file
@ -0,0 +1,997 @@
|
|||||||
|
import {
|
||||||
|
mdiBankOutline,
|
||||||
|
mdiBellOutline,
|
||||||
|
mdiCheckDecagramOutline,
|
||||||
|
mdiChartTimelineVariant,
|
||||||
|
mdiClipboardClockOutline,
|
||||||
|
mdiClipboardListOutline,
|
||||||
|
mdiFileDocumentOutline,
|
||||||
|
mdiPlus,
|
||||||
|
mdiShieldAlertOutline,
|
||||||
|
mdiWalletOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import NotificationBar from '../components/NotificationBar';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import {
|
||||||
|
getWorkspaceConfig,
|
||||||
|
type WorkspaceDetailBlockKey,
|
||||||
|
type WorkspaceMetricKey,
|
||||||
|
type WorkspaceQuickLinkIconKey,
|
||||||
|
} from '../helpers/workspace';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
|
interface BudgetByCurrency {
|
||||||
|
USD: number;
|
||||||
|
CDF: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
approvedBudget: BudgetByCurrency;
|
||||||
|
committedBudget: BudgetByCurrency;
|
||||||
|
disbursedBudget: BudgetByCurrency;
|
||||||
|
budgetVariance: BudgetByCurrency;
|
||||||
|
activeProjects: number;
|
||||||
|
procurementPipeline: number;
|
||||||
|
pendingApprovals: number;
|
||||||
|
contractsNearingExpiry: number;
|
||||||
|
overduePayments: number;
|
||||||
|
vendorComplianceAlerts: number;
|
||||||
|
openRiskAlerts: number;
|
||||||
|
unreadNotifications: number;
|
||||||
|
averageProjectProgress: number;
|
||||||
|
highRiskProjects: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalItem {
|
||||||
|
id: string;
|
||||||
|
recordType: string;
|
||||||
|
recordKey: string;
|
||||||
|
status: string;
|
||||||
|
requestedAt: string;
|
||||||
|
workflowName: string;
|
||||||
|
stepName: string;
|
||||||
|
stepOrder: number | null;
|
||||||
|
requestedBy: string;
|
||||||
|
assignedTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcurementItem {
|
||||||
|
id: string;
|
||||||
|
requisitionNumber: string;
|
||||||
|
title: string;
|
||||||
|
procurementMethod: string;
|
||||||
|
estimatedAmount: number;
|
||||||
|
currency: 'USD' | 'CDF';
|
||||||
|
neededByDate: string;
|
||||||
|
status: string;
|
||||||
|
provinceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractItem {
|
||||||
|
id: string;
|
||||||
|
contractNumber: string;
|
||||||
|
title: string;
|
||||||
|
contractValue: number;
|
||||||
|
currency: 'USD' | 'CDF';
|
||||||
|
endDate: string;
|
||||||
|
status: string;
|
||||||
|
vendorName: string;
|
||||||
|
projectName: string;
|
||||||
|
daysToExpiry?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskItem {
|
||||||
|
id: string;
|
||||||
|
alertType: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
details: string;
|
||||||
|
recordType: string;
|
||||||
|
recordKey: string;
|
||||||
|
dueAt: string;
|
||||||
|
status: string;
|
||||||
|
assignedTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProvinceItem {
|
||||||
|
provinceName: string;
|
||||||
|
totalProjects: number;
|
||||||
|
activeProjects: number;
|
||||||
|
averageCompletion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
read: boolean;
|
||||||
|
sentAt: string;
|
||||||
|
recordType: string;
|
||||||
|
recordKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFocusCard {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
note: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceWatchlistCard {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
note: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePayload {
|
||||||
|
roleName: string;
|
||||||
|
summaryMetricKeys: Array<keyof Summary>;
|
||||||
|
focusCards: WorkspaceFocusCard[];
|
||||||
|
watchlistCards: WorkspaceWatchlistCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutiveSummaryResponse {
|
||||||
|
workspace?: WorkspacePayload;
|
||||||
|
summary: Summary;
|
||||||
|
approvalQueue: ApprovalItem[];
|
||||||
|
procurementQueue: ProcurementItem[];
|
||||||
|
contractWatchlist: ContractItem[];
|
||||||
|
topContracts: ContractItem[];
|
||||||
|
riskPanel: RiskItem[];
|
||||||
|
provinceRollout: ProvinceItem[];
|
||||||
|
recentNotifications: NotificationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricDefinition {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
note: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultResponse: ExecutiveSummaryResponse = {
|
||||||
|
workspace: {
|
||||||
|
roleName: '',
|
||||||
|
summaryMetricKeys: [],
|
||||||
|
focusCards: [],
|
||||||
|
watchlistCards: [],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
approvedBudget: { USD: 0, CDF: 0 },
|
||||||
|
committedBudget: { USD: 0, CDF: 0 },
|
||||||
|
disbursedBudget: { USD: 0, CDF: 0 },
|
||||||
|
budgetVariance: { USD: 0, CDF: 0 },
|
||||||
|
activeProjects: 0,
|
||||||
|
procurementPipeline: 0,
|
||||||
|
pendingApprovals: 0,
|
||||||
|
contractsNearingExpiry: 0,
|
||||||
|
overduePayments: 0,
|
||||||
|
vendorComplianceAlerts: 0,
|
||||||
|
openRiskAlerts: 0,
|
||||||
|
unreadNotifications: 0,
|
||||||
|
averageProjectProgress: 0,
|
||||||
|
highRiskProjects: 0,
|
||||||
|
},
|
||||||
|
approvalQueue: [],
|
||||||
|
procurementQueue: [],
|
||||||
|
contractWatchlist: [],
|
||||||
|
topContracts: [],
|
||||||
|
riskPanel: [],
|
||||||
|
provinceRollout: [],
|
||||||
|
recentNotifications: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionIconMap: Record<WorkspaceQuickLinkIconKey, string> = {
|
||||||
|
organizations: mdiBankOutline,
|
||||||
|
users: mdiCheckDecagramOutline,
|
||||||
|
approvals: mdiClipboardClockOutline,
|
||||||
|
notifications: mdiBellOutline,
|
||||||
|
projects: mdiChartTimelineVariant,
|
||||||
|
contracts: mdiFileDocumentOutline,
|
||||||
|
payments: mdiWalletOutline,
|
||||||
|
allocations: mdiBankOutline,
|
||||||
|
requisitions: mdiClipboardListOutline,
|
||||||
|
tenders: mdiClipboardListOutline,
|
||||||
|
compliance: mdiShieldAlertOutline,
|
||||||
|
audit: mdiShieldAlertOutline,
|
||||||
|
milestones: mdiChartTimelineVariant,
|
||||||
|
vendors: mdiCheckDecagramOutline,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number, currency: 'USD' | 'CDF') =>
|
||||||
|
new Intl.NumberFormat(currency === 'CDF' ? 'fr-CD' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value || 0);
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const humanize = (value?: string | null) =>
|
||||||
|
(value || '—')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
|
||||||
|
const statusBadgeClass = (status?: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
case 'active':
|
||||||
|
case 'processed':
|
||||||
|
return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
||||||
|
case 'pending':
|
||||||
|
case 'submitted':
|
||||||
|
case 'under_review':
|
||||||
|
case 'in_tender':
|
||||||
|
case 'awarded':
|
||||||
|
case 'batched':
|
||||||
|
return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||||
|
case 'rejected':
|
||||||
|
case 'cancelled':
|
||||||
|
case 'expired':
|
||||||
|
case 'terminated':
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-red-50 text-red-700 border-red-200';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-100 text-slate-700 border-slate-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityBadgeClass = (severity?: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-rose-50 text-rose-700 border-rose-200';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-100 text-slate-700 border-slate-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecordLink = (recordType?: string, recordKey?: string) => {
|
||||||
|
if (!recordType || !recordKey) {
|
||||||
|
return '/approvals/approvals-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedDetailPages = new Set([
|
||||||
|
'requisitions',
|
||||||
|
'contracts',
|
||||||
|
'projects',
|
||||||
|
'vendors',
|
||||||
|
'tenders',
|
||||||
|
'awards',
|
||||||
|
'invoices',
|
||||||
|
'payment_requests',
|
||||||
|
'budget_reallocations',
|
||||||
|
'grants',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!allowedDetailPages.has(recordType)) {
|
||||||
|
return '/approvals/approvals-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${recordType}/${recordType}-view?id=${recordKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionHeader = ({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div className='flex items-center justify-between gap-4 border-b border-slate-200 pb-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>{eyebrow}</p>
|
||||||
|
<h3 className='mt-1 text-xl font-semibold text-slate-900'>{title}</h3>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SummaryMetric = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
note: string;
|
||||||
|
icon: string;
|
||||||
|
}) => (
|
||||||
|
<CardBox className='h-full border border-slate-200 bg-white'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>{title}</p>
|
||||||
|
<p className='mt-3 text-2xl font-semibold text-slate-900'>{value}</p>
|
||||||
|
<p className='mt-2 text-sm text-slate-500'>{note}</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-11 w-11 items-center justify-center rounded-md border border-slate-200 bg-slate-50'>
|
||||||
|
<BaseIcon path={icon} size={22} className='text-slate-700' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ExecutiveSummaryPage = () => {
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [data, setData] = useState<ExecutiveSummaryResponse>(defaultResponse);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const workspaceConfig = useMemo(() => getWorkspaceConfig(currentUser?.app_role?.name), [currentUser?.app_role?.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const response = await axios.get('/executive-summary');
|
||||||
|
setData(response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load executive summary', error);
|
||||||
|
setErrorMessage(error?.response?.data?.message || 'Unable to load the executive summary right now.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSummary();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const metricDefinitions = useMemo<Record<WorkspaceMetricKey, MetricDefinition>>(
|
||||||
|
() => ({
|
||||||
|
approvedBudget: {
|
||||||
|
title: 'Approved annual budget',
|
||||||
|
value: `${formatCurrency(data.summary.approvedBudget.USD, 'USD')} / ${formatCurrency(data.summary.approvedBudget.CDF, 'CDF')}`,
|
||||||
|
note: 'Approved and active allocation envelope across the institution.',
|
||||||
|
icon: mdiBankOutline,
|
||||||
|
},
|
||||||
|
committedBudget: {
|
||||||
|
title: 'Committed budget',
|
||||||
|
value: `${formatCurrency(data.summary.committedBudget.USD, 'USD')} / ${formatCurrency(data.summary.committedBudget.CDF, 'CDF')}`,
|
||||||
|
note: 'Open requisition exposure awaiting tendering, award, or conversion.',
|
||||||
|
icon: mdiClipboardListOutline,
|
||||||
|
},
|
||||||
|
disbursedBudget: {
|
||||||
|
title: 'Disbursed budget',
|
||||||
|
value: `${formatCurrency(data.summary.disbursedBudget.USD, 'USD')} / ${formatCurrency(data.summary.disbursedBudget.CDF, 'CDF')}`,
|
||||||
|
note: 'Processed payments already released to counterparties.',
|
||||||
|
icon: mdiWalletOutline,
|
||||||
|
},
|
||||||
|
pendingApprovals: {
|
||||||
|
title: 'Pending approvals',
|
||||||
|
value: `${data.summary.pendingApprovals}`,
|
||||||
|
note: 'Approval actions currently sitting in the institutional queue.',
|
||||||
|
icon: mdiClipboardClockOutline,
|
||||||
|
},
|
||||||
|
contractsNearingExpiry: {
|
||||||
|
title: 'Contracts nearing expiry',
|
||||||
|
value: `${data.summary.contractsNearingExpiry}`,
|
||||||
|
note: 'Active contracts due to expire within the next 60 days.',
|
||||||
|
icon: mdiFileDocumentOutline,
|
||||||
|
},
|
||||||
|
vendorComplianceAlerts: {
|
||||||
|
title: 'Vendor compliance alerts',
|
||||||
|
value: `${data.summary.vendorComplianceAlerts}`,
|
||||||
|
note: 'Open compliance and missing-document issues requiring follow-up.',
|
||||||
|
icon: mdiCheckDecagramOutline,
|
||||||
|
},
|
||||||
|
procurementPipeline: {
|
||||||
|
title: 'Procurement pipeline',
|
||||||
|
value: `${data.summary.procurementPipeline}`,
|
||||||
|
note: 'Live requisitions progressing through review, tender, and award.',
|
||||||
|
icon: mdiClipboardListOutline,
|
||||||
|
},
|
||||||
|
openRiskAlerts: {
|
||||||
|
title: 'Open risk alerts',
|
||||||
|
value: `${data.summary.openRiskAlerts}`,
|
||||||
|
note: 'High-priority compliance or control issues requiring intervention.',
|
||||||
|
icon: mdiShieldAlertOutline,
|
||||||
|
},
|
||||||
|
averageProjectProgress: {
|
||||||
|
title: 'Average project progress',
|
||||||
|
value: `${data.summary.averageProjectProgress}%`,
|
||||||
|
note: 'Average completion level across active and approved projects.',
|
||||||
|
icon: mdiChartTimelineVariant,
|
||||||
|
},
|
||||||
|
highRiskProjects: {
|
||||||
|
title: 'High-risk projects',
|
||||||
|
value: `${data.summary.highRiskProjects}`,
|
||||||
|
note: 'Projects flagged high or critical risk in active delivery.',
|
||||||
|
icon: mdiShieldAlertOutline,
|
||||||
|
},
|
||||||
|
overduePayments: {
|
||||||
|
title: 'Overdue payment requests',
|
||||||
|
value: `${data.summary.overduePayments}`,
|
||||||
|
note: 'Submitted, approved, or batched requests older than 30 days.',
|
||||||
|
icon: mdiWalletOutline,
|
||||||
|
},
|
||||||
|
activeProjects: {
|
||||||
|
title: 'Active projects',
|
||||||
|
value: `${data.summary.activeProjects}`,
|
||||||
|
note: 'Projects currently being executed across the organization.',
|
||||||
|
icon: mdiChartTimelineVariant,
|
||||||
|
},
|
||||||
|
unreadNotifications: {
|
||||||
|
title: 'Unread notifications',
|
||||||
|
value: `${data.summary.unreadNotifications}`,
|
||||||
|
note: 'System notices, workflow events, and alerts awaiting attention.',
|
||||||
|
icon: mdiBellOutline,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[data.summary],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryCards = useMemo(() => {
|
||||||
|
const summaryMetricKeys = data.workspace?.summaryMetricKeys?.length
|
||||||
|
? (data.workspace.summaryMetricKeys as WorkspaceMetricKey[])
|
||||||
|
: workspaceConfig.highlightedMetricKeys;
|
||||||
|
|
||||||
|
return summaryMetricKeys.map((metricKey) => ({
|
||||||
|
key: metricKey,
|
||||||
|
...metricDefinitions[metricKey],
|
||||||
|
}));
|
||||||
|
}, [data.workspace?.summaryMetricKeys, metricDefinitions, workspaceConfig.highlightedMetricKeys]);
|
||||||
|
|
||||||
|
const heroMetricChips = useMemo(
|
||||||
|
() => workspaceConfig.heroMetricKeys.slice(0, 4).map((metricKey) => metricDefinitions[metricKey]),
|
||||||
|
[metricDefinitions, workspaceConfig.heroMetricKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusBlock = Boolean(data.workspace?.focusCards?.length) && (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-4 xl:grid-cols-4'>
|
||||||
|
{data.workspace?.focusCards.map((card) => (
|
||||||
|
<CardBox key={card.key} className='border border-slate-200 bg-white'>
|
||||||
|
<div className='flex h-full flex-col justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>Role focus</p>
|
||||||
|
<h3 className='mt-2 text-lg font-semibold text-slate-900'>{card.title}</h3>
|
||||||
|
<p className='mt-4 text-3xl font-semibold tracking-tight text-slate-900'>{card.value}</p>
|
||||||
|
<p className='mt-3 text-sm leading-6 text-slate-500'>{card.note}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseButton href={card.href} color='whiteDark' label='Open related records' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryBlock = (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-4 xl:grid-cols-3'>
|
||||||
|
{summaryCards.map((card) => (
|
||||||
|
<SummaryMetric key={card.key} title={card.title} value={card.value} note={card.note} icon={card.icon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchlistBlock = Boolean(data.workspace?.watchlistCards?.length) && (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-4 xl:grid-cols-3'>
|
||||||
|
{data.workspace?.watchlistCards.map((card) => (
|
||||||
|
<CardBox key={card.key} className='border border-slate-200 bg-white'>
|
||||||
|
<div className='flex h-full flex-col justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>Watchlist</p>
|
||||||
|
<h3 className='mt-2 text-lg font-semibold text-slate-900'>{card.title}</h3>
|
||||||
|
<div className='mt-4 flex items-end gap-3'>
|
||||||
|
<p className='text-3xl font-semibold tracking-tight text-slate-900'>{card.count}</p>
|
||||||
|
<p className='pb-1 text-sm text-slate-500'>records in view</p>
|
||||||
|
</div>
|
||||||
|
<p className='mt-3 text-sm leading-6 text-slate-500'>{card.note}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseButton href={card.href} color='whiteDark' label='Open watchlist' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvalRiskBlock = (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.55fr,1fr]'>
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.approvalQueue.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.approvalQueue.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/approvals/approvals-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.approvalQueue.actionLabel || 'Open approvals'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className='py-10 text-sm text-slate-500'>Loading approval workload…</div>
|
||||||
|
) : data.approvalQueue.length ? (
|
||||||
|
<div className='mt-4 overflow-x-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-slate-200 text-sm'>
|
||||||
|
<thead>
|
||||||
|
<tr className='text-left text-xs uppercase tracking-[0.16em] text-slate-500'>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Workflow</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Step</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Requested by</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Assigned to</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Requested</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Record</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-slate-100'>
|
||||||
|
{data.approvalQueue.map((item) => (
|
||||||
|
<tr key={item.id} className='align-top'>
|
||||||
|
<td className='py-4 pr-4'>
|
||||||
|
<div className='font-medium text-slate-900'>{item.workflowName}</div>
|
||||||
|
<div className='mt-1'>
|
||||||
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${statusBadgeClass(item.status)}`}>
|
||||||
|
{humanize(item.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>
|
||||||
|
{item.stepOrder ? `Step ${item.stepOrder}` : '—'} · {item.stepName}
|
||||||
|
</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>{item.requestedBy}</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>{item.assignedTo}</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>{formatDate(item.requestedAt)}</td>
|
||||||
|
<td className='py-4 pr-4'>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<Link href={`/approvals/approvals-view?id=${item.id}`} className='font-medium text-blue-700 hover:text-blue-900'>
|
||||||
|
Open approval
|
||||||
|
</Link>
|
||||||
|
<Link href={getRecordLink(item.recordType, item.recordKey)} className='text-slate-600 hover:text-slate-900'>
|
||||||
|
{humanize(item.recordType)} record
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='mt-5 rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No pending approvals are queued right now. Start a new requisition to test the workflow end to end.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.riskPanel.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.riskPanel.title}
|
||||||
|
action={
|
||||||
|
<span className='rounded-md border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>
|
||||||
|
{data.summary.openRiskAlerts} open alerts
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-4 grid gap-3'>
|
||||||
|
<div className='rounded-md border border-red-100 bg-red-50 p-4'>
|
||||||
|
<p className='text-sm font-semibold text-red-800'>Budget headroom</p>
|
||||||
|
<p className='mt-2 text-lg font-semibold text-red-950'>
|
||||||
|
{formatCurrency(data.summary.budgetVariance.USD, 'USD')} / {formatCurrency(data.summary.budgetVariance.CDF, 'CDF')}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm text-red-700'>Remaining variance between approved allocations and current commitments.</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
|
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
|
||||||
|
<p className='text-2xl font-semibold text-amber-900'>{data.summary.overduePayments}</p>
|
||||||
|
<p className='mt-1 text-sm text-amber-800'>Overdue payment requests</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-md border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<p className='text-2xl font-semibold text-slate-900'>{data.summary.highRiskProjects}</p>
|
||||||
|
<p className='mt-1 text-sm text-slate-700'>High-risk projects</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 space-y-3'>
|
||||||
|
{data.riskPanel.length ? (
|
||||||
|
data.riskPanel.slice(0, 4).map((risk) => (
|
||||||
|
<Link href={getRecordLink(risk.recordType, risk.recordKey)} key={risk.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{risk.title}</p>
|
||||||
|
<p className='mt-1 text-sm text-slate-600'>{risk.details || humanize(risk.alertType)}</p>
|
||||||
|
<p className='mt-2 text-xs uppercase tracking-[0.16em] text-slate-400'>Assigned to {risk.assignedTo}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-semibold ${severityBadgeClass(risk.severity)}`}>
|
||||||
|
{humanize(risk.severity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No open compliance alerts are currently active.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const operationsBlock = (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr,1fr,1fr]'>
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.procurementQueue.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.procurementQueue.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/requisitions/requisitions-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.procurementQueue.actionLabel || 'All requisitions'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-4 space-y-3'>
|
||||||
|
<div className='grid gap-2 rounded-md border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white'>1</span>
|
||||||
|
Requisition created
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white'>2</span>
|
||||||
|
Internal approval
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white'>3</span>
|
||||||
|
Tender / consultation
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white'>4</span>
|
||||||
|
Award and contract
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white'>5</span>
|
||||||
|
Invoice and payment
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.procurementQueue.length ? (
|
||||||
|
data.procurementQueue.map((item) => (
|
||||||
|
<Link href={`/requisitions/requisitions-view?id=${item.id}`} key={item.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{item.requisitionNumber || 'Requisition'}</p>
|
||||||
|
<p className='mt-1 text-sm text-slate-700'>{item.title}</p>
|
||||||
|
<p className='mt-2 text-xs uppercase tracking-[0.16em] text-slate-400'>
|
||||||
|
{humanize(item.procurementMethod)} · {item.provinceName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='text-right'>
|
||||||
|
<p className='text-sm font-semibold text-slate-900'>{formatCurrency(item.estimatedAmount, item.currency)}</p>
|
||||||
|
<span className={`mt-2 inline-flex rounded-md border px-2 py-1 text-xs font-medium ${statusBadgeClass(item.status)}`}>
|
||||||
|
{humanize(item.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex items-center justify-between text-xs text-slate-500'>
|
||||||
|
<span>Needed by {formatDate(item.neededByDate)}</span>
|
||||||
|
<span className='font-medium text-blue-700'>Open record</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No live requisitions were found. Use “New requisition” to begin the procurement chain.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.contractWatchlist.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.contractWatchlist.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/contracts/contracts-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.contractWatchlist.actionLabel || 'Contract register'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 space-y-3'>
|
||||||
|
{data.contractWatchlist.length ? (
|
||||||
|
data.contractWatchlist.map((contract) => (
|
||||||
|
<Link href={`/contracts/contracts-view?id=${contract.id}`} key={contract.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{contract.contractNumber || 'Contract record'}</p>
|
||||||
|
<p className='mt-1 text-sm text-slate-700'>{contract.title}</p>
|
||||||
|
<p className='mt-2 text-xs uppercase tracking-[0.16em] text-slate-400'>{contract.vendorName}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${statusBadgeClass(contract.status)}`}>
|
||||||
|
{humanize(contract.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 grid grid-cols-2 gap-3 text-sm text-slate-600'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.16em] text-slate-400'>End date</p>
|
||||||
|
<p className='mt-1 font-medium text-slate-900'>{formatDate(contract.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.16em] text-slate-400'>Days to expiry</p>
|
||||||
|
<p className='mt-1 font-medium text-slate-900'>{contract.daysToExpiry ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No active contracts are expiring within the next 60 days.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.recentNotifications.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.recentNotifications.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/notifications/notifications-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.recentNotifications.actionLabel || 'Notification center'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 space-y-3'>
|
||||||
|
{data.recentNotifications.length ? (
|
||||||
|
data.recentNotifications.map((notification) => (
|
||||||
|
<Link href={getRecordLink(notification.recordType, notification.recordKey)} key={notification.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{notification.title}</p>
|
||||||
|
<p className='mt-1 line-clamp-2 text-sm text-slate-600'>{notification.message}</p>
|
||||||
|
</div>
|
||||||
|
<BaseIcon path={mdiBellOutline} size={18} className='text-slate-500' />
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex items-center justify-between text-xs text-slate-500'>
|
||||||
|
<span>{humanize(notification.type)}</span>
|
||||||
|
<span>{formatDate(notification.sentAt)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No recent notifications were returned for this user.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deliveryBlock = (
|
||||||
|
<div className='mb-6 grid grid-cols-1 gap-6 xl:grid-cols-[1fr,1fr]'>
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.provinceRollout.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.provinceRollout.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/projects/projects-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.provinceRollout.actionLabel || 'Projects register'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 space-y-4'>
|
||||||
|
{data.provinceRollout.length ? (
|
||||||
|
data.provinceRollout.map((province) => (
|
||||||
|
<div key={province.provinceName} className='rounded-md border border-slate-200 p-4'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{province.provinceName}</p>
|
||||||
|
<p className='mt-1 text-sm text-slate-500'>
|
||||||
|
{province.activeProjects} active of {province.totalProjects} total projects
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='text-right'>
|
||||||
|
<p className='text-sm font-semibold text-slate-900'>{province.averageCompletion}%</p>
|
||||||
|
<p className='text-xs uppercase tracking-[0.16em] text-slate-400'>Average completion</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 h-2 rounded-full bg-slate-100'>
|
||||||
|
<div className='h-2 rounded-full bg-slate-800' style={{ width: `${Math.min(province.averageCompletion, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No provincial rollout data is available yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={workspaceConfig.sectionCopy.topContracts.eyebrow}
|
||||||
|
title={workspaceConfig.sectionCopy.topContracts.title}
|
||||||
|
action={
|
||||||
|
<BaseButton
|
||||||
|
href='/vendors/vendors-list'
|
||||||
|
color='whiteDark'
|
||||||
|
label={workspaceConfig.sectionCopy.topContracts.actionLabel || 'Vendor master'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 overflow-x-auto'>
|
||||||
|
{data.topContracts.length ? (
|
||||||
|
<table className='min-w-full divide-y divide-slate-200 text-sm'>
|
||||||
|
<thead>
|
||||||
|
<tr className='text-left text-xs uppercase tracking-[0.16em] text-slate-500'>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Contract</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Vendor</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Project</th>
|
||||||
|
<th className='py-3 pr-4 font-semibold'>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-slate-100'>
|
||||||
|
{data.topContracts.map((contract) => (
|
||||||
|
<tr key={contract.id}>
|
||||||
|
<td className='py-4 pr-4'>
|
||||||
|
<Link href={`/contracts/contracts-view?id=${contract.id}`} className='font-medium text-slate-900 hover:text-blue-700'>
|
||||||
|
{contract.contractNumber || contract.title}
|
||||||
|
</Link>
|
||||||
|
<p className='mt-1 text-slate-500'>{contract.title}</p>
|
||||||
|
</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>{contract.vendorName}</td>
|
||||||
|
<td className='py-4 pr-4 text-slate-600'>{contract.projectName}</td>
|
||||||
|
<td className='py-4 pr-4 font-medium text-slate-900'>{formatCurrency(contract.contractValue, contract.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500'>
|
||||||
|
No contract commitments are available yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionsBlock = (
|
||||||
|
<CardBox className='border border-slate-200 bg-slate-50'>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>{workspaceConfig.sectionCopy.quickActions.eyebrow}</p>
|
||||||
|
<h3 className='mt-1 text-xl font-semibold text-slate-900'>{workspaceConfig.sectionCopy.quickActions.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 lg:grid-cols-4'>
|
||||||
|
{workspaceConfig.quickLinks.map((link) => (
|
||||||
|
<Link href={link.href} key={`${link.href}-${link.label}`} className='rounded-md border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:bg-slate-100'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<BaseIcon path={actionIconMap[link.icon]} size={18} className='text-slate-700' />
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{link.label}</p>
|
||||||
|
<p className='text-sm text-slate-500'>{link.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockMap: Partial<Record<WorkspaceDetailBlockKey, ReactNode>> = {
|
||||||
|
focus: focusBlock,
|
||||||
|
summary: summaryBlock,
|
||||||
|
watchlist: watchlistBlock,
|
||||||
|
approvalRisk: approvalRiskBlock,
|
||||||
|
operations: operationsBlock,
|
||||||
|
delivery: deliveryBlock,
|
||||||
|
actions: actionsBlock,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle(workspaceConfig.pageTitle)}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={workspaceConfig.pageTitle} main>
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton href={workspaceConfig.primaryAction.href} color='info' icon={mdiPlus} label={workspaceConfig.primaryAction.label} />
|
||||||
|
<BaseButton href={workspaceConfig.secondaryAction.href} color='whiteDark' label={workspaceConfig.secondaryAction.label} />
|
||||||
|
</BaseButtons>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox className='mb-6 border border-slate-200 bg-slate-950 text-slate-100'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[1.9fr,1fr]'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-400'>{workspaceConfig.eyebrow}</p>
|
||||||
|
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-white'>{workspaceConfig.heroTitle}</h2>
|
||||||
|
<p className='mt-4 max-w-3xl text-sm leading-6 text-slate-300'>{workspaceConfig.heroDescription}</p>
|
||||||
|
<div className='mt-5 flex flex-wrap gap-3 text-sm text-slate-300'>
|
||||||
|
{heroMetricChips.map((metric) => (
|
||||||
|
<span key={metric.title} className='rounded-md border border-slate-800 bg-slate-900 px-3 py-2'>
|
||||||
|
{metric.title}: {metric.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 text-sm'>
|
||||||
|
<div className='rounded-md border border-slate-800 bg-slate-900/80 p-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>User context</p>
|
||||||
|
<p className='mt-2 text-lg font-semibold text-white'>{currentUser?.firstName || currentUser?.email || 'Authenticated user'}</p>
|
||||||
|
<p className='mt-1 text-slate-400'>{data.workspace?.roleName || currentUser?.app_role?.name || 'Operational access'}</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-md border border-slate-800 bg-slate-900/80 p-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Control indicators</p>
|
||||||
|
<div className='mt-3 grid grid-cols-2 gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-semibold text-white'>{data.summary.overduePayments}</p>
|
||||||
|
<p className='text-slate-400'>Overdue payment requests</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-2xl font-semibold text-white'>{data.summary.unreadNotifications}</p>
|
||||||
|
<p className='text-slate-400'>Unread notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{errorMessage && <NotificationBar color='danger'>{errorMessage}</NotificationBar>}
|
||||||
|
|
||||||
|
{workspaceConfig.blockOrder.map((blockKey) => {
|
||||||
|
const block = blockMap[blockKey];
|
||||||
|
|
||||||
|
if (!block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <React.Fragment key={blockKey}>{block}</React.Fragment>;
|
||||||
|
})}
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ExecutiveSummaryPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExecutiveSummaryPage;
|
||||||
@ -1,166 +1,190 @@
|
|||||||
|
import {
|
||||||
import React, { useEffect, useState } from 'react';
|
mdiBankOutline,
|
||||||
|
mdiCheckDecagramOutline,
|
||||||
|
mdiClipboardClockOutline,
|
||||||
|
mdiFileDocumentOutline,
|
||||||
|
mdiLogin,
|
||||||
|
mdiMapMarkerRadiusOutline,
|
||||||
|
mdiOfficeBuildingCogOutline,
|
||||||
|
mdiShieldCheckOutline,
|
||||||
|
mdiWalletOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const moduleCards = [
|
||||||
|
{
|
||||||
|
title: 'Budgeting and fund allocation',
|
||||||
|
description: 'Approved allocations, commitment exposure, reallocations, and fiscal-year control across funding programs.',
|
||||||
|
icon: mdiBankOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Procurement and approvals',
|
||||||
|
description: 'Requisition-to-award workflow with internal approvals, tender visibility, and contract handoff.',
|
||||||
|
icon: mdiClipboardClockOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Contracts and vendors',
|
||||||
|
description: 'Vendor qualification, compliance review, contract expiry monitoring, and payment readiness.',
|
||||||
|
icon: mdiFileDocumentOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Projects and rollout oversight',
|
||||||
|
description: 'Province-level implementation tracking for school broadband, digital centers, rural connectivity, and ICT programs.',
|
||||||
|
icon: mdiMapMarkerRadiusOutline,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
const controlPoints = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
'Approval-aware procurement workflow from requisition to payment',
|
||||||
src: undefined,
|
'Vendor compliance, document expiry, and audit-ready record trails',
|
||||||
photographer: undefined,
|
'Executive summary with budget, contract, project, and risk visibility',
|
||||||
photographer_url: undefined,
|
'Operational links into the existing admin interface and CRUD work areas',
|
||||||
})
|
];
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('background');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'FDSU ERP'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('FDSU ERP')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className='min-h-screen bg-slate-100 text-slate-900'>
|
||||||
<div
|
<header className='border-b border-slate-200 bg-white'>
|
||||||
className={`flex ${
|
<div className='mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-4'>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div>
|
||||||
} min-h-screen w-full`}
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500'>National public fund authority platform</p>
|
||||||
>
|
<h1 className='mt-1 text-2xl font-semibold tracking-tight text-slate-950'>FDSU ERP</h1>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your FDSU ERP app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
|
<BaseButton href='/login' color='whiteDark' icon={mdiLogin} label='Login' />
|
||||||
|
<BaseButton href='/dashboard' color='info' icon={mdiOfficeBuildingCogOutline} label='Admin interface' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<BaseButtons>
|
<main className='mx-auto max-w-7xl px-6 py-8'>
|
||||||
<BaseButton
|
<section className='grid gap-6 xl:grid-cols-[1.55fr,1fr]'>
|
||||||
href='/login'
|
<CardBox className='border border-slate-200 bg-slate-950 text-slate-100'>
|
||||||
label='Login'
|
<div className='max-w-4xl'>
|
||||||
color='info'
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-400'>Institutional ERP foundation</p>
|
||||||
className='w-full'
|
<h2 className='mt-4 text-4xl font-semibold tracking-tight text-white'>
|
||||||
/>
|
Operational control for budgeting, procurement, contract management, compliance, and digital inclusion rollout.
|
||||||
|
</h2>
|
||||||
|
<p className='mt-5 max-w-3xl text-base leading-7 text-slate-300'>
|
||||||
|
FDSU ERP is designed as a serious working environment for finance, procurement, project delivery, internal control, audit, and executive leadership.
|
||||||
|
It centralizes recordkeeping, approvals, payment oversight, vendor qualification, contract obligations, and province-level implementation in one platform.
|
||||||
|
</p>
|
||||||
|
|
||||||
</BaseButtons>
|
<div className='mt-8 flex flex-wrap gap-3'>
|
||||||
</CardBox>
|
<BaseButton href='/executive-summary' color='info' label='Open executive summary' />
|
||||||
</div>
|
<BaseButton href='/requisitions/requisitions-new' color='whiteDark' label='Create requisition' />
|
||||||
|
<BaseButton href='/approvals/approvals-list' color='whiteDark' label='Open approval inbox' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500'>What the first delivery includes</p>
|
||||||
|
<div className='mt-5 space-y-3'>
|
||||||
|
{controlPoints.map((item) => (
|
||||||
|
<div key={item} className='flex items-start gap-3 rounded-md border border-slate-200 bg-slate-50 px-4 py-3'>
|
||||||
|
<span className='mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-white'>
|
||||||
|
<BaseIcon path={mdiShieldCheckOutline} size={14} />
|
||||||
|
</span>
|
||||||
|
<p className='text-sm leading-6 text-slate-700'>{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<BaseDivider />
|
||||||
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
|
<div className='rounded-md border border-slate-200 p-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.18em] text-slate-400'>Interface style</p>
|
||||||
|
<p className='mt-2 text-sm font-medium text-slate-900'>Dense, audit-conscious, line-of-business layout</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-md border border-slate-200 p-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.18em] text-slate-400'>Access</p>
|
||||||
|
<p className='mt-2 text-sm font-medium text-slate-900'>Public landing with links into the secured admin workspace</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className='mt-8 grid gap-4 lg:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
{moduleCards.map((card) => (
|
||||||
|
<CardBox key={card.title} className='h-full border border-slate-200 bg-white'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>ERP module</p>
|
||||||
|
<h3 className='mt-3 text-lg font-semibold text-slate-900'>{card.title}</h3>
|
||||||
|
<p className='mt-3 text-sm leading-6 text-slate-600'>{card.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-11 w-11 items-center justify-center rounded-md border border-slate-200 bg-slate-50'>
|
||||||
|
<BaseIcon path={card.icon} size={20} className='text-slate-700' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className='mt-8 grid gap-6 xl:grid-cols-[1.2fr,1fr]'>
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500'>Operational workflow</p>
|
||||||
|
<h3 className='mt-2 text-2xl font-semibold text-slate-900'>Thin-slice journey now wired into the app</h3>
|
||||||
|
<div className='mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
{[
|
||||||
|
['1', 'Create requisition', 'Capture procurement demand and route it into the formal approval chain.'],
|
||||||
|
['2', 'Review approvals', 'Use the new executive summary and approval inbox to identify pending actions.'],
|
||||||
|
['3', 'Inspect contracts', 'Track contracts nearing expiry and top-value commitments in one place.'],
|
||||||
|
['4', 'Monitor rollout', 'Review project concentration by province and outstanding operational risk.'],
|
||||||
|
].map(([step, title, text]) => (
|
||||||
|
<div key={step} className='rounded-md border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white'>{step}</div>
|
||||||
|
<p className='mt-4 font-medium text-slate-900'>{title}</p>
|
||||||
|
<p className='mt-2 text-sm leading-6 text-slate-600'>{text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='border border-slate-200 bg-white'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500'>Quick access</p>
|
||||||
|
<div className='mt-5 grid gap-3'>
|
||||||
|
{[
|
||||||
|
{ href: '/executive-summary', icon: mdiWalletOutline, title: 'Executive summary', text: 'Operational overview, approval queue, risk panel, and rollout indicators.' },
|
||||||
|
{ href: '/requisitions/requisitions-list', icon: mdiClipboardClockOutline, title: 'Requisitions', text: 'Create, list, and review procurement requests.' },
|
||||||
|
{ href: '/contracts/contracts-list', icon: mdiFileDocumentOutline, title: 'Contracts', text: 'Open the contract register and milestone detail views.' },
|
||||||
|
{ href: '/vendors/vendors-list', icon: mdiCheckDecagramOutline, title: 'Vendor master', text: 'Access qualification, banking data, compliance, and related history.' },
|
||||||
|
].map((item) => (
|
||||||
|
<Link key={item.href} href={item.href} className='rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<div className='mt-0.5 flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-slate-50'>
|
||||||
|
<BaseIcon path={item.icon} size={18} className='text-slate-700' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium text-slate-900'>{item.title}</p>
|
||||||
|
<p className='mt-1 text-sm leading-6 text-slate-600'>{item.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user