Enforced approval rights
This commit is contained in:
@@ -105,18 +105,18 @@ export const MonitoringPage: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tabParam = searchParams.get('tab');
|
||||
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
|
||||
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
|
||||
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'risks'>(
|
||||
tabParam === 'evidence' || tabParam === 'risks' ? tabParam : 'contracts'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const t = searchParams.get('tab');
|
||||
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
|
||||
if (t === 'evidence' || t === 'risks' || t === 'contracts') {
|
||||
setActiveTab(t);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
|
||||
const handleTabChange = (tab: 'contracts' | 'evidence' | 'risks') => {
|
||||
setActiveTab(tab);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
@@ -176,7 +176,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
|
||||
// Forms
|
||||
const indicatorForm = useForm<IndicatorFormValues>({
|
||||
resolver: zodResolver(indicatorSchema) as any,
|
||||
resolver: zodResolver(indicatorSchema),
|
||||
defaultValues: {
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
name: '',
|
||||
@@ -187,7 +187,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const riskForm = useForm<RiskFormValues>({
|
||||
resolver: zodResolver(riskSchema) as any,
|
||||
resolver: zodResolver(riskSchema),
|
||||
defaultValues: {
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
name: '',
|
||||
@@ -436,15 +436,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||
{activeTab === 'indicators' && canAdd('evidence_risks') && (
|
||||
<button
|
||||
onClick={() => setIsIndicatorModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Define Indicator</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeTab === 'risks' && canAdd('evidence_risks') && (
|
||||
<button
|
||||
onClick={openCreateRisk}
|
||||
@@ -493,23 +485,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('indicators')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||
activeTab === 'indicators'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Indicators & Actuals ({indicators.length})</span>
|
||||
<HelpTooltip
|
||||
title="Quantitative Indicators"
|
||||
content="Baseline vs. target metric progression with automated green/amber/red performance thresholds."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleTabChange('risks')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||
@@ -544,217 +520,6 @@ export const MonitoringPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Indicators Table (M08) */}
|
||||
{activeTab === 'indicators' && (
|
||||
<div className="space-y-4">
|
||||
{/* Indicators Search and Filter Toolbar */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search indicators, statement, project ID..."
|
||||
value={indicatorSearchTerm}
|
||||
onChange={(e) => setIndicatorSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
||||
<select
|
||||
value={indicatorProjectFilter}
|
||||
onChange={(e) => setIndicatorProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||
>
|
||||
<option value="all">All Projects ({projects.length})</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} (#{p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||
Status:
|
||||
</label>
|
||||
<select
|
||||
value={indicatorStatusFilter}
|
||||
onChange={(e) => setIndicatorStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="green">≥90% Target Achieved</option>
|
||||
<option value="amber">70–89% On Track</option>
|
||||
<option value="red"><70% Underperforming</option>
|
||||
<option value="pending">Pending Actuals</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Unit Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
|
||||
<select
|
||||
value={indicatorUnitFilter}
|
||||
onChange={(e) => setIndicatorUnitFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||
>
|
||||
<option value="all">All Units</option>
|
||||
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIndicatorSearchTerm('');
|
||||
setIndicatorProjectFilter('all');
|
||||
setIndicatorStatusFilter('all');
|
||||
setIndicatorUnitFilter('all');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons: Refresh & Export */}
|
||||
<div className="flex items-center gap-2 self-end lg:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchIndicators();
|
||||
success('Refreshed', 'Latest performance metrics loaded from API.');
|
||||
}}
|
||||
disabled={isFetchingIndicators}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||
title="Refresh indicators from API"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingIndicators ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="evidence_risks"
|
||||
title="M&E Performance Indicators"
|
||||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={indicatorExportColumns}
|
||||
data={filteredIndicators}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators Table Card */}
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||
Performance Indicators & Actuals Ledger
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
Showing {filteredIndicators.length} of {indicators.length} indicators
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
|
||||
Indicator Name
|
||||
</SortableTh>
|
||||
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||||
Project ID
|
||||
</SortableTh>
|
||||
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||||
Unit of Measure
|
||||
</SortableTh>
|
||||
<SortableTh column="baselineAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Baseline
|
||||
</SortableTh>
|
||||
<SortableTh column="targetAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Target
|
||||
</SortableTh>
|
||||
<SortableTh column="actualAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Actual Recorded
|
||||
</SortableTh>
|
||||
<SortableTh column="status" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||||
Status
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingIndicators ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||
Loading project indicators from API...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedIndicators.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||||
? 'No indicators match your filter criteria.'
|
||||
: 'No performance indicators defined yet. Click "Define Indicator" to begin.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedIndicators.map((ind) => {
|
||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||
return (
|
||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
||||
{ind.actualAmount}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
||||
{statusObj.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedIndicatorForActual(ind);
|
||||
setActualInput(ind.actualAmount);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||||
>
|
||||
Record Actual
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Risk Register & Heatmap (M09) */}
|
||||
{activeTab === 'risks' && (
|
||||
<div className="space-y-6">
|
||||
@@ -970,7 +735,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
subtitle="Logical Framework & Theory of Change metric"
|
||||
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
|
||||
>
|
||||
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
|
||||
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">Project *</label>
|
||||
@@ -1157,7 +922,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
|
||||
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
|
||||
>
|
||||
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
|
||||
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data))} className="space-y-4">
|
||||
{!editingRisk && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
|
||||
Reference in New Issue
Block a user