998 lines
39 KiB
TypeScript
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;
|