39462-vm/frontend/src/pages/executive-summary.tsx
2026-04-04 03:49:52 +00:00

998 lines
39 KiB
TypeScript

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;