Implemented session and account lockout management
Enabled account sync control and session expiration management Applied some fixes to the govername page and project page
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
getAllRisks,
|
||||
getAllIndicators,
|
||||
} from '../services/api';
|
||||
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
|
||||
import { ApprovalStatus } from '../types';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
|
||||
export const ExecutiveDashboard: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
||||
const [selectedSector, setSelectedSector] = useState('All');
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
|
||||
@@ -73,61 +76,140 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
queryFn: () => getAllRisks(),
|
||||
});
|
||||
|
||||
const { data: indicators = [] } = useQuery({
|
||||
queryKey: ['indicators'],
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
// Headline metrics sourced directly from live API models
|
||||
// Filtered projects according to active Programme and Sector dropdown selections
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
if (selectedProgramme !== 'All' && String(p.programmeId) !== selectedProgramme) {
|
||||
return false;
|
||||
}
|
||||
if (selectedSector !== 'All') {
|
||||
const sectorLabel = getIndustrySectorLabel(p.sector);
|
||||
if (sectorLabel !== selectedSector) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projects, selectedProgramme, selectedSector]);
|
||||
|
||||
// Filtered risks associated with the filtered project set
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (selectedProgramme === 'All' && selectedSector === 'All') {
|
||||
return risks;
|
||||
}
|
||||
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
|
||||
return risks.filter((r) => projectIds.has(String(r.projectId)));
|
||||
}, [risks, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
// Headline metrics sourced directly from live API models with real filtering
|
||||
const portfolioValue = useMemo(() => {
|
||||
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
|
||||
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
|
||||
const filteredBudgets = budgets.filter((b) => projectIds.has(String(b.projectId)));
|
||||
const budgetTotal = filteredBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
if (budgetTotal > 0) return budgetTotal;
|
||||
const filteredAwards = awards.filter((a) => projectIds.has(String(a.projectId)));
|
||||
const awardsTotal = filteredAwards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
return awardsTotal;
|
||||
}
|
||||
if (dashboard?.portfolioValue && dashboard.portfolioValue > 0) {
|
||||
return dashboard.portfolioValue;
|
||||
}
|
||||
const budgetTotal = budgets.reduce((sum, b) => sum + (b.amount || 0), 0);
|
||||
const budgetTotal = budgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
if (budgetTotal > 0) return budgetTotal;
|
||||
const awardsTotal = awards.reduce((sum, a) => sum + (a.amount || 0), 0);
|
||||
const awardsTotal = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
return awardsTotal;
|
||||
}, [dashboard, budgets, awards]);
|
||||
}, [dashboard, budgets, awards, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
const activeAwards = dashboard?.activeAwards ?? (awards.length || projects.length);
|
||||
const activeAwards = useMemo(() => {
|
||||
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
|
||||
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
|
||||
const matched = awards.filter((a) => projectIds.has(String(a.projectId)));
|
||||
return matched.length || filteredProjects.length;
|
||||
}
|
||||
if (awards.length > 0) return awards.length;
|
||||
return dashboard?.activeAwards ?? projects.length;
|
||||
}, [dashboard, awards, filteredProjects, projects, selectedProgramme, selectedSector]);
|
||||
|
||||
const disbursed = useMemo(() => {
|
||||
if (dashboard?.disbursed && dashboard.disbursed > 0) {
|
||||
return dashboard.disbursed;
|
||||
}
|
||||
return disbursements
|
||||
const relevantDisbursements = disbursements.filter((d) => {
|
||||
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
|
||||
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
|
||||
return projectIds.has(String(d.projectId));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const approvedTotal = relevantDisbursements
|
||||
.filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3)
|
||||
.reduce((sum, d) => sum + (d.amount || 0), 0);
|
||||
}, [dashboard, disbursements]);
|
||||
.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
|
||||
if (approvedTotal > 0) return approvedTotal;
|
||||
|
||||
const allTotal = relevantDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
if (allTotal > 0 && (!dashboard?.disbursed || dashboard.disbursed === 0)) {
|
||||
return allTotal;
|
||||
}
|
||||
|
||||
return dashboard?.disbursed || 0;
|
||||
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
const onTrackPercentage = useMemo(() => {
|
||||
if (dashboard?.onTrackPercentage !== undefined && dashboard.onTrackPercentage > 0) {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 0;
|
||||
|
||||
const onTrack = targetProjects.filter(
|
||||
(p) => p.status === ApprovalStatus.Approved || (p.status as number) === 3 || (p.status as unknown as string) === 'Active'
|
||||
).length;
|
||||
|
||||
if (onTrack === 0 && dashboard?.onTrackPercentage && dashboard.onTrackPercentage > 0) {
|
||||
return dashboard.onTrackPercentage;
|
||||
}
|
||||
if (projects.length === 0) return 0;
|
||||
const activeOnTrack = projects.filter((p) => p.status === ApprovalStatus.Approved).length;
|
||||
return Math.round((activeOnTrack / projects.length) * 100);
|
||||
}, [dashboard, projects]);
|
||||
return Math.round((onTrack / targetProjects.length) * 100);
|
||||
}, [dashboard, filteredProjects, projects]);
|
||||
|
||||
// Dynamically calculate At Risk count directly from live registered risks
|
||||
const atRiskCount = useMemo(() => {
|
||||
if (dashboard?.atRiskCount !== undefined) {
|
||||
return dashboard.atRiskCount;
|
||||
}
|
||||
return risks.filter((r) => r.likelihood * r.impact >= 12).length;
|
||||
}, [dashboard, risks]);
|
||||
const targetRisks = filteredRisks;
|
||||
const highRisks = targetRisks.filter((r) => {
|
||||
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
|
||||
return score >= 12 || ((Number(r.impact) || 1) >= 4 && (Number(r.likelihood) || 1) >= 3);
|
||||
});
|
||||
|
||||
const beneficiariesReached = dashboard?.beneficiariesReached ?? 0;
|
||||
if (targetRisks.length > 0) {
|
||||
return highRisks.length;
|
||||
}
|
||||
return dashboard?.atRiskCount ?? 0;
|
||||
}, [dashboard, filteredRisks]);
|
||||
|
||||
const beneficiariesReached = useMemo(() => {
|
||||
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
|
||||
return dashboard.beneficiariesReached;
|
||||
}
|
||||
const sumActual = indicators.reduce((sum, ind) => sum + (Number(ind.actualAmount) || 0), 0);
|
||||
return sumActual > 0 ? sumActual : 0;
|
||||
}, [dashboard, indicators]);
|
||||
|
||||
// Real sector breakdown calculated dynamically from actual project records
|
||||
const sectorData = useMemo(() => {
|
||||
if (!projects || projects.length === 0) return [];
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return [];
|
||||
const counts: Record<string, number> = {};
|
||||
projects.forEach((p) => {
|
||||
targetProjects.forEach((p) => {
|
||||
const label = getIndustrySectorLabel(p.sector);
|
||||
counts[label] = (counts[label] || 0) + 1;
|
||||
});
|
||||
|
||||
const colors = ['bg-emerald-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500', 'bg-purple-500', 'bg-amber-500'];
|
||||
const totalProjects = projects.length;
|
||||
const totalProjects = targetProjects.length;
|
||||
|
||||
return Object.entries(counts).map(([name, count], index) => {
|
||||
const percentage = Math.round((count / totalProjects) * 100);
|
||||
@@ -140,27 +222,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
color: colors[index % colors.length],
|
||||
};
|
||||
});
|
||||
}, [projects, portfolioValue]);
|
||||
|
||||
// Risk heatmap matrix computed directly from registered risk records
|
||||
const riskHeatmap = useMemo(() => {
|
||||
const matrix = {
|
||||
highImpHighLik: risks.filter((r) => r.impact >= 4 && r.likelihood >= 4).length,
|
||||
highImpMedLik: risks.filter((r) => r.impact >= 4 && r.likelihood === 3).length,
|
||||
highImpLowLik: risks.filter((r) => r.impact >= 4 && r.likelihood <= 2).length,
|
||||
|
||||
medImpHighLik: risks.filter((r) => r.impact === 3 && r.likelihood >= 4).length,
|
||||
medImpMedLik: risks.filter((r) => r.impact === 3 && r.likelihood === 3).length,
|
||||
medImpLowLik: risks.filter((r) => r.impact === 3 && r.likelihood <= 2).length,
|
||||
|
||||
lowImpHighLik: risks.filter((r) => r.impact <= 2 && r.likelihood >= 4).length,
|
||||
lowImpMedLik: risks.filter((r) => r.impact <= 2 && r.likelihood === 3).length,
|
||||
lowImpLowLik: risks.filter((r) => r.impact <= 2 && r.likelihood <= 2).length,
|
||||
|
||||
flaggedCount: risks.filter((r) => r.impact * r.likelihood >= 12).length,
|
||||
};
|
||||
return matrix;
|
||||
}, [risks]);
|
||||
}, [projects, filteredProjects, portfolioValue]);
|
||||
|
||||
// Quarterly disbursements distribution computed from real disbursement items
|
||||
const quarterlyTrend = useMemo(() => {
|
||||
@@ -198,26 +260,59 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
}));
|
||||
}, [disbursements]);
|
||||
|
||||
// Attention Projects derived directly from live project data
|
||||
// Attention Projects derived directly from live project data & risk scores
|
||||
const attentionProjects = useMemo(() => {
|
||||
if (!projects || projects.length === 0) return [];
|
||||
return projects
|
||||
.filter((p) => p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review)
|
||||
.slice(0, 5)
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return [];
|
||||
|
||||
// Map each project to its highest residual risk score
|
||||
const projectMaxRisk: Record<string, number> = {};
|
||||
risks.forEach((r) => {
|
||||
const pid = String(r.projectId);
|
||||
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
|
||||
if (!projectMaxRisk[pid] || score > projectMaxRisk[pid]) {
|
||||
projectMaxRisk[pid] = score;
|
||||
}
|
||||
});
|
||||
|
||||
return targetProjects
|
||||
.map((p) => {
|
||||
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
||||
const isRejected = p.status === ApprovalStatus.Rejected;
|
||||
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
||||
|
||||
let riskLevel: 'RED' | 'AMBER' | 'GREEN' = 'GREEN';
|
||||
let statusLabel = 'Active';
|
||||
let variance = '0%';
|
||||
let varianceType: 'negative' | 'positive' = 'positive';
|
||||
|
||||
if (isRejected || maxScore >= 15) {
|
||||
riskLevel = 'RED';
|
||||
statusLabel = 'At Risk';
|
||||
variance = '-15%';
|
||||
varianceType = 'negative';
|
||||
} else if (isPending || maxScore >= 10) {
|
||||
riskLevel = 'AMBER';
|
||||
statusLabel = isPending ? 'Pending' : 'Review';
|
||||
variance = '-5%';
|
||||
varianceType = 'negative';
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||
status: isRejected ? 'At Risk' : isPending ? 'Pending' : 'Active',
|
||||
risk: isRejected ? 'RED' : isPending ? 'AMBER' : 'GREEN',
|
||||
financialVariance: isRejected ? '-15%' : '0%',
|
||||
varianceType: isRejected ? 'negative' : 'positive',
|
||||
status: statusLabel,
|
||||
risk: riskLevel,
|
||||
financialVariance: variance,
|
||||
varianceType,
|
||||
maxScore,
|
||||
};
|
||||
});
|
||||
}, [projects]);
|
||||
})
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER')
|
||||
.sort((a, b) => b.maxScore - a.maxScore)
|
||||
.slice(0, 5);
|
||||
}, [projects, filteredProjects, risks]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
@@ -384,15 +479,15 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Models Section (Sector, Heatmap, Impact Trend) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Visual Models Section (Sector Breakdown & Disbursements) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Panel 1: Funding by Sector */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
|
||||
<span className="text-[11px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/30">
|
||||
API Calculated
|
||||
Live Portfolio
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -436,75 +531,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel 2: Portfolio Risk Heatmap */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||
<h3 className="text-sm font-semibold text-white">Portfolio Risk Heatmap</h3>
|
||||
<span className="text-[11px] font-mono text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-semibold">
|
||||
{riskHeatmap.flaggedCount} Flagged
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 my-2">
|
||||
Matrix distribution of project risks by Likelihood (1–5) and Impact (1–5).
|
||||
</p>
|
||||
|
||||
{/* 3x3 Heatmap Matrix with real counted risks */}
|
||||
<div className="grid grid-cols-3 gap-2 my-4">
|
||||
{/* Row 1 - High Impact */}
|
||||
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
|
||||
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
|
||||
{riskHeatmap.highImpLowLik} {riskHeatmap.highImpLowLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-rose-950/50 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/60">
|
||||
<span className="text-[10px] block text-rose-400 font-normal">High</span>
|
||||
{riskHeatmap.highImpMedLik} {riskHeatmap.highImpMedLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-rose-900/80 text-rose-100 p-2.5 rounded-xl text-center text-xs font-bold border border-rose-500 shadow-md">
|
||||
<span className="text-[10px] block text-rose-300 font-normal">Critical</span>
|
||||
{riskHeatmap.highImpHighLik} {riskHeatmap.highImpHighLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
|
||||
{/* Row 2 - Medium Impact */}
|
||||
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
|
||||
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
|
||||
{riskHeatmap.medImpLowLik} {riskHeatmap.medImpLowLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
|
||||
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
|
||||
{riskHeatmap.medImpMedLik} {riskHeatmap.medImpMedLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-rose-950/40 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/50">
|
||||
<span className="text-[10px] block text-rose-400 font-normal">High</span>
|
||||
{riskHeatmap.medImpHighLik} {riskHeatmap.medImpHighLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
|
||||
{/* Row 3 - Low Impact */}
|
||||
<div className="bg-slate-900/80 text-slate-300 p-2.5 rounded-xl text-center text-xs font-semibold border border-slate-800">
|
||||
<span className="text-[10px] block text-slate-500 font-normal">Trivial</span>
|
||||
{riskHeatmap.lowImpLowLik} {riskHeatmap.lowImpLowLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
|
||||
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
|
||||
{riskHeatmap.lowImpMedLik} {riskHeatmap.lowImpMedLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
|
||||
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
|
||||
{riskHeatmap.lowImpHighLik} {riskHeatmap.lowImpHighLik === 1 ? 'Risk' : 'Risks'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-800/80 text-[11px] text-slate-400 flex justify-between items-center">
|
||||
<span>Total Registered Risks: {risks.length}</span>
|
||||
<Link to="/monitoring" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
|
||||
Risk Register <ArrowUpRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel 3: Funding-to-Impact Trend */}
|
||||
{/* Panel 2: Disbursement Distribution */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||
@@ -558,6 +585,14 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel 3: Portfolio Risk Heatmap & Matrix */}
|
||||
<RiskHeatmapMatrix
|
||||
risks={filteredRisks}
|
||||
projects={projects}
|
||||
title="Portfolio Risk Heatmap & Matrix"
|
||||
subtitle="Aggregated ISO 31000 distribution of project risks by Likelihood (1–5) and Impact (1–5)"
|
||||
/>
|
||||
|
||||
{/* Projects Requiring Attention Table */}
|
||||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-lg backdrop-blur-sm">
|
||||
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Search,
|
||||
Coins,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
import {
|
||||
getOrganisationTypeLabel,
|
||||
getActivityStatusBadge,
|
||||
getOrganisationStatusBadge,
|
||||
getApprovalStatusBadge,
|
||||
formatDate,
|
||||
getUserEmail,
|
||||
@@ -192,7 +194,7 @@ export const GovernancePage: React.FC = () => {
|
||||
orgForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to save organisation', String(err));
|
||||
toastError('Failed to save organisation', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -217,7 +219,7 @@ export const GovernancePage: React.FC = () => {
|
||||
portfolioForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to save portfolio', String(err));
|
||||
toastError('Failed to save portfolio', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -242,7 +244,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to save programme', String(err));
|
||||
toastError('Failed to save programme', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,17 +411,31 @@ export const GovernancePage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
{/* Search Bar & Actions */}
|
||||
{activeTab === 'organisations' && (
|
||||
<div className="relative max-w-md">
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by organisation name, reg no, or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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 className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by organisation name, reg no, or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
success('Refreshed', 'Organisation directory refreshed from database.');
|
||||
}}
|
||||
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"
|
||||
title="Force refresh organisation directory from database"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
||||
Refresh Directory
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -453,7 +469,7 @@ export const GovernancePage: React.FC = () => {
|
||||
</tr>
|
||||
) : (
|
||||
filteredOrgs.map((org) => {
|
||||
const statusBadge = getActivityStatusBadge(org.status);
|
||||
const statusBadge = getOrganisationStatusBadge(org);
|
||||
return (
|
||||
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
Link as LinkIcon,
|
||||
Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAwards,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
getDisbursements,
|
||||
getDisbursement,
|
||||
createDisbursement,
|
||||
updateDisbursement,
|
||||
approveDisbursement,
|
||||
getOrganisations,
|
||||
getAllProgrammes,
|
||||
@@ -122,6 +124,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const [selectedContractForExecution, setSelectedContractForExecution] = useState<Contract | null>(null);
|
||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: awards = [], isLoading: loadingAwards } = useQuery({
|
||||
@@ -227,7 +230,7 @@ export const GrantsPage: React.FC = () => {
|
||||
awardForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Award registration failed', String(err));
|
||||
toastError('Award registration failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -240,7 +243,7 @@ export const GrantsPage: React.FC = () => {
|
||||
contractForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Contract creation failed', String(err));
|
||||
toastError('Contract creation failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -256,7 +259,7 @@ export const GrantsPage: React.FC = () => {
|
||||
executeForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Execution trigger failed', String(err));
|
||||
toastError('Execution trigger failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -269,23 +272,60 @@ export const GrantsPage: React.FC = () => {
|
||||
budgetForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Budget creation failed', String(err));
|
||||
toastError('Budget creation failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const disbursementMutation = useMutation({
|
||||
mutationFn: (data: CreateDisbursementRequest) => createDisbursement(data),
|
||||
mutationFn: async (data: DisbursementFormValues) => {
|
||||
if (editingDisbursement) {
|
||||
return updateDisbursement({
|
||||
disbursementId: editingDisbursement.id,
|
||||
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
|
||||
amount: Number(data.amount),
|
||||
});
|
||||
}
|
||||
return createDisbursement({
|
||||
projectId: Number(data.projectId),
|
||||
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
|
||||
amount: Number(data.amount),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
success('Disbursement Scheduled', 'Payment tranche added in Pending Review state');
|
||||
success(
|
||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||
editingDisbursement ? 'Payment tranche details updated' : 'Payment tranche added in Pending Review state'
|
||||
);
|
||||
setIsDisbursementModalOpen(false);
|
||||
setEditingDisbursement(null);
|
||||
disbursementForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Disbursement creation failed', String(err));
|
||||
toastError(editingDisbursement ? 'Disbursement update failed' : 'Disbursement creation failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateDisbursement = () => {
|
||||
setEditingDisbursement(null);
|
||||
disbursementForm.reset({
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
milestoneId: undefined,
|
||||
amount: 150000,
|
||||
});
|
||||
setIsDisbursementModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditDisbursement = (d: Disbursement) => {
|
||||
setEditingDisbursement(d);
|
||||
disbursementForm.reset({
|
||||
projectId: Number(d.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
|
||||
milestoneId: d.milestoneId ? Number(d.milestoneId) : undefined,
|
||||
amount: d.amount,
|
||||
});
|
||||
setIsDisbursementModalOpen(true);
|
||||
};
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||
onSuccess: () => {
|
||||
@@ -297,7 +337,7 @@ export const GrantsPage: React.FC = () => {
|
||||
);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Disbursement approval failed', String(err));
|
||||
toastError('Disbursement approval failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,7 +361,7 @@ export const GrantsPage: React.FC = () => {
|
||||
toastError('Download Error', 'No active secure download link generated.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toastError('Link Error', err?.message || 'Failed to fetch contract secure link.');
|
||||
toastError('Link Error', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -331,7 +371,7 @@ export const GrantsPage: React.FC = () => {
|
||||
success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
} catch (err: any) {
|
||||
toastError('Upload Failed', err?.message || 'Contract file upload failed.');
|
||||
toastError('Upload Failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -341,7 +381,7 @@ export const GrantsPage: React.FC = () => {
|
||||
success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
} catch (err: any) {
|
||||
toastError('Update Failed', err?.message || 'Failed to update contract status.');
|
||||
toastError('Update Failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -398,7 +438,7 @@ export const GrantsPage: React.FC = () => {
|
||||
)}
|
||||
{activeTab === 'disbursements' && (
|
||||
<button
|
||||
onClick={() => setIsDisbursementModalOpen(true)}
|
||||
onClick={openCreateDisbursement}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -735,21 +775,33 @@ export const GrantsPage: React.FC = () => {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Released
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Approve Payment
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Released
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditDisbursement(d)}
|
||||
title="Edit Disbursement"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 transition cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Approve Payment
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -1052,27 +1104,36 @@ export const GrantsPage: React.FC = () => {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Schedule Disbursement */}
|
||||
{/* Modal: Schedule or Edit Disbursement */}
|
||||
<Modal
|
||||
isOpen={isDisbursementModalOpen}
|
||||
onClose={() => setIsDisbursementModalOpen(false)}
|
||||
title="Schedule Disbursement Tranche"
|
||||
subtitle="Link disbursement to milestone verification and conditions precedent"
|
||||
onClose={() => {
|
||||
setIsDisbursementModalOpen(false);
|
||||
setEditingDisbursement(null);
|
||||
}}
|
||||
title={editingDisbursement ? 'Edit Disbursement Tranche' : 'Schedule Disbursement Tranche'}
|
||||
subtitle={
|
||||
editingDisbursement
|
||||
? `Updating payment tranche DSB-${editingDisbursement.id}`
|
||||
: 'Link disbursement to milestone verification and conditions precedent'
|
||||
}
|
||||
>
|
||||
<form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
|
||||
<select
|
||||
{...disbursementForm.register('projectId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!editingDisbursement && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
|
||||
<select
|
||||
{...disbursementForm.register('projectId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Linked Milestone Deliverable</label>
|
||||
@@ -1101,7 +1162,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDisbursementModalOpen(false)}
|
||||
onClick={() => {
|
||||
setIsDisbursementModalOpen(false);
|
||||
setEditingDisbursement(null);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
|
||||
>
|
||||
Cancel
|
||||
@@ -1111,7 +1175,11 @@ export const GrantsPage: React.FC = () => {
|
||||
disabled={disbursementMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
|
||||
>
|
||||
{disbursementMutation.isPending ? 'Scheduling...' : 'Schedule Disbursement'}
|
||||
{disbursementMutation.isPending
|
||||
? 'Saving...'
|
||||
: editingDisbursement
|
||||
? 'Update Disbursement'
|
||||
: 'Schedule Disbursement'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider';
|
||||
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||
|
||||
export const LandingPage: React.FC = () => {
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEnterConsole = async () => {
|
||||
@@ -70,9 +70,13 @@ export const LandingPage: React.FC = () => {
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="header-nav-console-btn"
|
||||
className="flex items-center gap-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 px-3.5 py-1.5 rounded-lg shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
className={`flex items-center gap-2 text-xs font-semibold text-white px-3.5 py-1.5 rounded-lg shadow-md transition cursor-pointer ${
|
||||
isPendingActivation
|
||||
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-600/20'
|
||||
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20'
|
||||
}`}
|
||||
>
|
||||
<span>Go to Console</span>
|
||||
<span>{isPendingActivation ? 'Check Activation Status' : 'Go to Console'}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
@@ -191,19 +195,36 @@ export const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Guidance Message */}
|
||||
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
||||
<p className="font-semibold text-white mb-1">Session Verified</p>
|
||||
You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session.
|
||||
</div>
|
||||
{isPendingActivation ? (
|
||||
<div className="p-4 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 leading-relaxed space-y-1">
|
||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
Account Pending Administrator Activation
|
||||
</p>
|
||||
<p>
|
||||
You are authenticated, but your account has not yet been enabled in the directory by an administrator.
|
||||
Click below to enter the console and check your activation status.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
||||
<p className="font-semibold text-white mb-1">Session Verified</p>
|
||||
You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation and Sign Out Buttons */}
|
||||
<div className="space-y-3 pt-1">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="btn-return-to-console"
|
||||
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer"
|
||||
className={`w-full py-3.5 px-5 rounded-xl font-semibold text-sm text-white shadow-lg flex items-center justify-center gap-2 transition cursor-pointer ${
|
||||
isPendingActivation
|
||||
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-600/25'
|
||||
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/25'
|
||||
}`}
|
||||
>
|
||||
<span>Return to PFM Console</span>
|
||||
<span>{isPendingActivation ? 'Enter Console & Sync Status' : 'Return to PFM Console'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -222,9 +243,9 @@ export const LandingPage: React.FC = () => {
|
||||
<KeyRound className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>OAuth2 / OpenID Connect</span>
|
||||
</span>
|
||||
<span className="text-emerald-400 font-medium text-[11px] flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||
Verified Session
|
||||
<span className={`${isPendingActivation ? 'text-amber-400' : 'text-emerald-400'} font-medium text-[11px] flex items-center gap-1`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isPendingActivation ? 'bg-amber-400' : 'bg-emerald-400'} inline-block`}></span>
|
||||
{isPendingActivation ? 'Awaiting Admin Enablement' : 'Verified Session'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,9 +254,9 @@ export const LandingPage: React.FC = () => {
|
||||
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
|
||||
|
||||
<div className="text-center space-y-3 pt-2">
|
||||
<div className="flex justify-center">
|
||||
<BrandLogo size={48} showGlow animateOnHover={false} />
|
||||
<div className="text-center space-y-2 pt-2">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
|
||||
@@ -275,12 +296,8 @@ export const LandingPage: React.FC = () => {
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BrandLogo size={20} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-slate-400 font-medium">Post-Fund Management, Performance & Impact System</span>
|
||||
</div>
|
||||
<p className="text-slate-500">Enterprise Governance & Audit Console</p>
|
||||
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 text-center text-xs text-slate-500">
|
||||
<p>Post-Fund Management, Performance & Impact System • Enterprise Governance Console</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Check,
|
||||
Filter,
|
||||
Link as LinkIcon,
|
||||
Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAllIndicators,
|
||||
@@ -31,11 +32,13 @@ import {
|
||||
updateEvidence,
|
||||
getAllRisks,
|
||||
createRisk,
|
||||
updateRisk,
|
||||
getProjects,
|
||||
getAllMilestones,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
import {
|
||||
Indicator,
|
||||
Evidence,
|
||||
@@ -90,6 +93,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
// Modals & States
|
||||
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
|
||||
const [isRiskModalOpen, setIsRiskModalOpen] = useState(false);
|
||||
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
|
||||
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
||||
const [actualInput, setActualInput] = useState<number>(0);
|
||||
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
|
||||
@@ -160,7 +164,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
indicatorForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to create indicator', String(err));
|
||||
toastError('Failed to create indicator', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -173,7 +177,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
setSelectedIndicatorForActual(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to record actual', String(err));
|
||||
toastError('Failed to record actual', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +188,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Upload failed', String(err));
|
||||
toastError('Upload failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -209,21 +213,66 @@ export const MonitoringPage: React.FC = () => {
|
||||
setReviewerComment('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Review update failed', String(err));
|
||||
toastError('Review update failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateRisk = () => {
|
||||
setEditingRisk(null);
|
||||
riskForm.reset({
|
||||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||||
name: '',
|
||||
description: '',
|
||||
likelihood: RiskLikelihood.Medium,
|
||||
impact: RiskImpact.Moderate,
|
||||
});
|
||||
setIsRiskModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditRisk = (rk: Risk) => {
|
||||
setEditingRisk(rk);
|
||||
riskForm.reset({
|
||||
projectId: Number(rk.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
|
||||
name: rk.name || '',
|
||||
description: rk.description || '',
|
||||
likelihood: (rk.likelihood as RiskLikelihood) ?? RiskLikelihood.Medium,
|
||||
impact: (rk.impact as RiskImpact) ?? RiskImpact.Moderate,
|
||||
});
|
||||
setIsRiskModalOpen(true);
|
||||
};
|
||||
|
||||
const riskMutation = useMutation({
|
||||
mutationFn: (data: CreateRiskRequest) => createRisk(data),
|
||||
mutationFn: async (data: RiskFormValues) => {
|
||||
if (editingRisk) {
|
||||
return updateRisk({
|
||||
riskId: editingRisk.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
likelihood: Number(data.likelihood),
|
||||
impact: Number(data.impact),
|
||||
});
|
||||
}
|
||||
return createRisk({
|
||||
projectId: Number(data.projectId),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
likelihood: Number(data.likelihood),
|
||||
impact: Number(data.impact),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['risks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Risk registered', 'Risk item added to project register');
|
||||
success(
|
||||
editingRisk ? 'Risk updated' : 'Risk registered',
|
||||
editingRisk ? 'Risk profile updated in project register' : 'Risk item added to project register'
|
||||
);
|
||||
setIsRiskModalOpen(false);
|
||||
setEditingRisk(null);
|
||||
riskForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to log risk', String(err));
|
||||
toastError(editingRisk ? 'Failed to update risk' : 'Failed to log risk', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,7 +298,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
toastError('Download Error', 'No active secure download link generated.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toastError('Link Error', err?.message || 'Failed to retrieve evidence secure link.');
|
||||
toastError('Link Error', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,7 +337,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
)}
|
||||
{activeTab === 'risks' && (
|
||||
<button
|
||||
onClick={() => setIsRiskModalOpen(true)}
|
||||
onClick={openCreateRisk}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -614,57 +663,98 @@ export const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Risk Register (M09) */}
|
||||
{/* Tab 3: Risk Register & Heatmap (M09) */}
|
||||
{activeTab === 'risks' && (
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<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">
|
||||
<th className="py-3.5 px-5">Risk Title & Description</th>
|
||||
<th className="py-3.5 px-4">Project ID</th>
|
||||
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Residual Score</th>
|
||||
<th className="py-3.5 px-4">Risk Owner</th>
|
||||
<th className="py-3.5 px-5">Mitigation Control</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingRisks ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading risk register...
|
||||
</td>
|
||||
<div className="space-y-6">
|
||||
{/* Live Heatmap Matrix with cell click inspection */}
|
||||
<RiskHeatmapMatrix
|
||||
risks={risks}
|
||||
projects={projects}
|
||||
onSelectRisk={(r) => openEditRisk(r)}
|
||||
title="Project Risk Heatmap & Matrix"
|
||||
subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls"
|
||||
/>
|
||||
|
||||
{/* Risk Register Table */}
|
||||
<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 bg-slate-900/60">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white">Project Risk Register</h4>
|
||||
<p className="text-[11px] text-slate-400">Complete itemized audit log of identified risks and residual scores</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateRisk}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Risk
|
||||
</button>
|
||||
</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">
|
||||
<th className="py-3.5 px-5">Risk Title & Description</th>
|
||||
<th className="py-3.5 px-4">Project ID</th>
|
||||
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Residual Score</th>
|
||||
<th className="py-3.5 px-4">Risk Owner</th>
|
||||
<th className="py-3.5 px-5">Mitigation Control</th>
|
||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
) : (
|
||||
risks.map((rk) => {
|
||||
const score = rk.likelihood * rk.impact;
|
||||
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||
return (
|
||||
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
<span className="font-semibold text-white block text-xs">{rk.name}</span>
|
||||
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
|
||||
{score} / 25 ({color.level})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
|
||||
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
|
||||
{rk.mitigationAction || 'Standard internal audit controls applied.'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingRisks ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
||||
Loading risk register...
|
||||
</td>
|
||||
</tr>
|
||||
) : risks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
||||
No risks recorded in the register. Click "Add Risk" to log your first risk item.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
risks.map((rk) => {
|
||||
const score = rk.likelihood * rk.impact;
|
||||
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||
return (
|
||||
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
<span className="font-semibold text-white block text-xs">{rk.name}</span>
|
||||
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
|
||||
{score} / 25 ({color.level})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
|
||||
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
|
||||
{rk.mitigationAction || 'Standard internal audit controls applied.'}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<button
|
||||
onClick={() => openEditRisk(rk)}
|
||||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700/60 rounded-lg transition"
|
||||
title="Edit Risk"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -803,27 +893,32 @@ export const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Risk */}
|
||||
{/* Modal: Add or Edit Risk */}
|
||||
<Modal
|
||||
isOpen={isRiskModalOpen}
|
||||
onClose={() => setIsRiskModalOpen(false)}
|
||||
title="Add Risk to Risk Register"
|
||||
subtitle="Identify and score project delivery risks"
|
||||
onClose={() => {
|
||||
setIsRiskModalOpen(false);
|
||||
setEditingRisk(null);
|
||||
}}
|
||||
title={editingRisk ? 'Edit Project Risk' : 'Add Risk to Risk Register'}
|
||||
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
|
||||
>
|
||||
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
|
||||
<select
|
||||
{...riskForm.register('projectId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!editingRisk && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
|
||||
<select
|
||||
{...riskForm.register('projectId')}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label>
|
||||
@@ -877,7 +972,10 @@ export const MonitoringPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRiskModalOpen(false)}
|
||||
onClick={() => {
|
||||
setIsRiskModalOpen(false);
|
||||
setEditingRisk(null);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
|
||||
>
|
||||
Cancel
|
||||
@@ -887,7 +985,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
disabled={riskMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
|
||||
>
|
||||
{riskMutation.isPending ? 'Logging...' : 'Log Risk'}
|
||||
{riskMutation.isPending ? 'Saving...' : editingRisk ? 'Update Risk' : 'Log Risk'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,15 +17,19 @@ import {
|
||||
FileCheck,
|
||||
TrendingUp,
|
||||
X,
|
||||
Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
updateProjectStatus,
|
||||
getActivities,
|
||||
createActivity,
|
||||
updateActivity,
|
||||
getMilestones,
|
||||
createMilestone,
|
||||
updateMilestone,
|
||||
getAllProgrammes,
|
||||
getProgrammeProjects,
|
||||
getActivity,
|
||||
@@ -37,6 +41,8 @@ import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import {
|
||||
Project,
|
||||
Activity,
|
||||
Milestone,
|
||||
IndustrySector,
|
||||
ProjectType,
|
||||
ApprovalStatus,
|
||||
@@ -44,6 +50,8 @@ import {
|
||||
CreateProjectRequest,
|
||||
CreateActivityRequest,
|
||||
CreateMilestoneRequest,
|
||||
UpdateActivityRequest,
|
||||
UpdateMilestoneRequest,
|
||||
} from '../types';
|
||||
import {
|
||||
getIndustrySectorLabel,
|
||||
@@ -52,6 +60,7 @@ import {
|
||||
getPriorityBadge,
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getUserEmail,
|
||||
INDUSTRY_SECTOR_OPTIONS,
|
||||
PROJECT_TYPE_OPTIONS,
|
||||
PRIORITY_OPTIONS,
|
||||
@@ -71,12 +80,14 @@ const activitySchema = z.object({
|
||||
description: z.string().min(5, 'Description required'),
|
||||
category: z.string().min(2, 'Category required'),
|
||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
||||
milestoneId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const milestoneSchema = z.object({
|
||||
name: z.string().min(4, 'Milestone deliverable required'),
|
||||
dueAt: z.string().min(4, 'Due date is mandatory'),
|
||||
priority: z.coerce.number().min(1).max(4),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type ProjectFormValues = z.infer<typeof projectSchema>;
|
||||
@@ -91,10 +102,15 @@ export const ProjectsPage: React.FC = () => {
|
||||
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
|
||||
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
|
||||
|
||||
// Modals
|
||||
// Modals & Editing State
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
|
||||
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
|
||||
const [editingActivity, setEditingActivity] = useState<Activity | null>(null);
|
||||
|
||||
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
|
||||
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: programmes = [] } = useQuery({
|
||||
@@ -156,6 +172,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
description: '',
|
||||
category: '',
|
||||
amount: 0,
|
||||
milestoneId: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -165,21 +182,108 @@ export const ProjectsPage: React.FC = () => {
|
||||
name: '',
|
||||
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
priority: Priority.High,
|
||||
status: ApprovalStatus.Pending,
|
||||
},
|
||||
});
|
||||
|
||||
// Open Handlers
|
||||
const openCreateProject = () => {
|
||||
setEditingProject(null);
|
||||
projectForm.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
|
||||
sector: IndustrySector.AgriTech,
|
||||
projectType: ProjectType.Innovation,
|
||||
});
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditProject = (p: Project) => {
|
||||
setEditingProject(p);
|
||||
projectForm.reset({
|
||||
name: p.name || '',
|
||||
description: p.description || '',
|
||||
programmeId: p.programmeId ? Number(p.programmeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
|
||||
sector: p.sector || IndustrySector.AgriTech,
|
||||
projectType: p.projectType || ProjectType.Innovation,
|
||||
});
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateActivity = () => {
|
||||
setEditingActivity(null);
|
||||
activityForm.reset({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
amount: 0,
|
||||
milestoneId: '',
|
||||
});
|
||||
setIsActivityModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditActivity = (a: Activity) => {
|
||||
setEditingActivity(a);
|
||||
activityForm.reset({
|
||||
title: a.title || '',
|
||||
description: a.description || '',
|
||||
category: a.category || '',
|
||||
amount: a.amount || 0,
|
||||
milestoneId: a.milestoneId ? String(a.milestoneId) : '',
|
||||
});
|
||||
setIsActivityModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateMilestone = () => {
|
||||
setEditingMilestone(null);
|
||||
milestoneForm.reset({
|
||||
name: '',
|
||||
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
priority: Priority.High,
|
||||
status: ApprovalStatus.Pending,
|
||||
});
|
||||
setIsMilestoneModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditMilestone = (m: Milestone) => {
|
||||
setEditingMilestone(m);
|
||||
milestoneForm.reset({
|
||||
name: m.name || '',
|
||||
dueAt: m.dueAt ? m.dueAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
priority: m.priority || Priority.High,
|
||||
status: m.status || ApprovalStatus.Pending,
|
||||
});
|
||||
setIsMilestoneModalOpen(true);
|
||||
};
|
||||
|
||||
// Mutations
|
||||
const projectMutation = useMutation({
|
||||
mutationFn: (data: CreateProjectRequest) => createProject(data),
|
||||
onSuccess: (newProj) => {
|
||||
mutationFn: (data: ProjectFormValues) => {
|
||||
if (editingProject) {
|
||||
return updateProject({
|
||||
projectId: editingProject.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
sector: Number(data.sector),
|
||||
projectType: Number(data.projectType),
|
||||
});
|
||||
}
|
||||
return createProject(data as CreateProjectRequest);
|
||||
},
|
||||
onSuccess: (savedProj) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success('Project registered', `Project "${newProj.name}" created successfully`);
|
||||
success(
|
||||
editingProject ? 'Project updated' : 'Project registered',
|
||||
`Project "${savedProj.name}" saved successfully`
|
||||
);
|
||||
setIsProjectModalOpen(false);
|
||||
setEditingProject(null);
|
||||
projectForm.reset();
|
||||
setSelectedProject(newProj);
|
||||
setSelectedProject(savedProj);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to create project', String(err));
|
||||
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,33 +298,88 @@ export const ProjectsPage: React.FC = () => {
|
||||
success('Status updated', 'Project governance status changed successfully');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to update status', String(err));
|
||||
toastError('Failed to update status', err);
|
||||
},
|
||||
});
|
||||
|
||||
const activityMutation = useMutation({
|
||||
mutationFn: (data: CreateActivityRequest) => createActivity(data),
|
||||
mutationFn: (data: ActivityFormValues) => {
|
||||
const parsedMilestoneId =
|
||||
data.milestoneId && String(data.milestoneId).trim() !== ''
|
||||
? Number(data.milestoneId)
|
||||
: null;
|
||||
|
||||
if (editingActivity) {
|
||||
return updateActivity({
|
||||
activityId: editingActivity.id,
|
||||
projectId: activeProjectId!,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: Number(data.amount),
|
||||
milestoneId: parsedMilestoneId,
|
||||
});
|
||||
}
|
||||
return createActivity({
|
||||
projectId: activeProjectId!,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: Number(data.amount),
|
||||
milestoneId: parsedMilestoneId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
|
||||
success('Activity added', 'Activity registered to project work breakdown');
|
||||
success(
|
||||
editingActivity ? 'Activity updated' : 'Activity added',
|
||||
editingActivity
|
||||
? 'Activity updated in project work breakdown'
|
||||
: 'Activity registered to project work breakdown'
|
||||
);
|
||||
setIsActivityModalOpen(false);
|
||||
setEditingActivity(null);
|
||||
activityForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to add activity', String(err));
|
||||
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
|
||||
},
|
||||
});
|
||||
|
||||
const milestoneMutation = useMutation({
|
||||
mutationFn: (data: CreateMilestoneRequest) => createMilestone(data),
|
||||
mutationFn: async (data: MilestoneFormValues): Promise<void> => {
|
||||
if (editingMilestone) {
|
||||
await updateMilestone({
|
||||
milestoneId: editingMilestone.id,
|
||||
name: data.name,
|
||||
dueAt: data.dueAt,
|
||||
priority: Number(data.priority),
|
||||
status: Number(data.status ?? editingMilestone.status),
|
||||
});
|
||||
} else {
|
||||
await createMilestone({
|
||||
projectId: activeProjectId!,
|
||||
name: data.name,
|
||||
dueAt: data.dueAt,
|
||||
priority: Number(data.priority),
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
|
||||
success('Milestone created', 'Contractual deliverable added to implementation schedule');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success(
|
||||
editingMilestone ? 'Milestone updated' : 'Milestone created',
|
||||
editingMilestone
|
||||
? 'Contractual deliverable updated successfully'
|
||||
: 'Contractual deliverable added to implementation schedule'
|
||||
);
|
||||
setIsMilestoneModalOpen(false);
|
||||
setEditingMilestone(null);
|
||||
milestoneForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to add milestone', String(err));
|
||||
toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -247,7 +406,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsProjectModalOpen(true)}
|
||||
onClick={openCreateProject}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -331,27 +490,47 @@ export const ProjectsPage: React.FC = () => {
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white mt-1.5 leading-snug">{selectedProject.name}</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{selectedProject.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2.5 text-[11px] text-slate-400 font-medium">
|
||||
<span>
|
||||
Created by: <strong className="text-slate-200 font-normal">{getUserEmail(selectedProject.createdBy)}</strong>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Sector: <strong className="text-slate-200 font-normal">{getIndustrySectorLabel(selectedProject.sector)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Change Selector */}
|
||||
<div className="shrink-0 text-right">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
|
||||
<select
|
||||
value={selectedProject.status}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: selectedProject.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
{/* Actions & Status Change Selector */}
|
||||
<div className="shrink-0 text-right space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProject(selectedProject)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||||
title="Edit project details"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||
Edit Project
|
||||
</button>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
|
||||
<select
|
||||
value={selectedProject.status}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: selectedProject.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -433,7 +612,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
|
||||
<button
|
||||
onClick={() => setIsActivityModalOpen(true)}
|
||||
onClick={openCreateActivity}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@@ -445,19 +624,44 @@ export const ProjectsPage: React.FC = () => {
|
||||
<p className="text-xs text-slate-500 py-4 text-center">No activities recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{activities.map((a) => (
|
||||
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="font-semibold text-xs text-white">{a.title}</span>
|
||||
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
|
||||
{activities.map((a) => {
|
||||
const linkedMilestone = a.milestoneId
|
||||
? milestones.find((m) => String(m.id) === String(a.milestoneId))
|
||||
: null;
|
||||
return (
|
||||
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-semibold text-xs text-white block">{a.title}</span>
|
||||
{a.milestoneId ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
|
||||
<Flag className="w-2.5 h-2.5" />
|
||||
Milestone #{a.milestoneId}
|
||||
{linkedMilestone ? ` - ${linkedMilestone.name}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Independent Activity</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
|
||||
<button
|
||||
onClick={() => openEditActivity(a)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Edit activity"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5 leading-relaxed">{a.description}</p>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
|
||||
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
|
||||
<span>Date: {formatDate(a.performedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{a.description}</p>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
|
||||
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
|
||||
<span>Date: {formatDate(a.performedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,7 +673,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
|
||||
<button
|
||||
onClick={() => setIsMilestoneModalOpen(true)}
|
||||
onClick={openCreateMilestone}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@@ -487,10 +691,22 @@ export const ProjectsPage: React.FC = () => {
|
||||
return (
|
||||
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-xs text-white">{m.name}</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-semibold text-xs text-white block">{m.name}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => openEditMilestone(m)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Edit contractual milestone"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -574,12 +790,15 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal: Create Project */}
|
||||
{/* Modal: Create or Edit Project */}
|
||||
<Modal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
title="Create Funded Project"
|
||||
subtitle="Operational entity under an approved Programme"
|
||||
onClose={() => {
|
||||
setIsProjectModalOpen(false);
|
||||
setEditingProject(null);
|
||||
}}
|
||||
title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'}
|
||||
subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'}
|
||||
>
|
||||
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
@@ -595,19 +814,21 @@ export const ProjectsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
|
||||
<select
|
||||
{...projectForm.register('programmeId')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{programmes.map((pr) => (
|
||||
<option key={pr.id} value={pr.id}>
|
||||
{pr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!editingProject && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
|
||||
<select
|
||||
{...projectForm.register('programmeId')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{programmes.map((pr) => (
|
||||
<option key={pr.id} value={pr.id}>
|
||||
{pr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -652,7 +873,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProjectModalOpen(false)}
|
||||
onClick={() => {
|
||||
setIsProjectModalOpen(false);
|
||||
setEditingProject(null);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
@@ -662,26 +886,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={projectMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
|
||||
>
|
||||
{projectMutation.isPending ? 'Creating...' : 'Register Project'}
|
||||
{projectMutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Activity */}
|
||||
{/* Modal: Add or Edit Activity */}
|
||||
<Modal
|
||||
isOpen={isActivityModalOpen}
|
||||
onClose={() => setIsActivityModalOpen(false)}
|
||||
title="Add Activity to Implementation Plan"
|
||||
subtitle={`Registering activity under Project #${activeProjectId}`}
|
||||
onClose={() => {
|
||||
setIsActivityModalOpen(false);
|
||||
setEditingActivity(null);
|
||||
}}
|
||||
title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'}
|
||||
subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={activityForm.handleSubmit((data) =>
|
||||
activityMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...data,
|
||||
} as any)
|
||||
)}
|
||||
onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
@@ -694,6 +916,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Associated Milestone Dropdown (FR: Passing milestoneId to PUT/POST request) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Associated Milestone (Deliverable Link)
|
||||
</label>
|
||||
<select
|
||||
{...activityForm.register('milestoneId')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- No Milestone / Standalone Activity --</option>
|
||||
{milestones.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
Milestone #{m.id}: {m.name} (Due: {formatDate(m.dueAt)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Allocation (ZAR) *</label>
|
||||
@@ -726,7 +966,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActivityModalOpen(false)}
|
||||
onClick={() => {
|
||||
setIsActivityModalOpen(false);
|
||||
setEditingActivity(null);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
@@ -736,26 +979,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={activityMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
|
||||
>
|
||||
{activityMutation.isPending ? 'Saving...' : 'Add Activity'}
|
||||
{activityMutation.isPending ? 'Saving...' : editingActivity ? 'Update Activity' : 'Add Activity'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Milestone */}
|
||||
{/* Modal: Add or Edit Milestone */}
|
||||
<Modal
|
||||
isOpen={isMilestoneModalOpen}
|
||||
onClose={() => setIsMilestoneModalOpen(false)}
|
||||
title="Define Contractual Milestone"
|
||||
subtitle={`Deliverable schedule for Project #${activeProjectId}`}
|
||||
onClose={() => {
|
||||
setIsMilestoneModalOpen(false);
|
||||
setEditingMilestone(null);
|
||||
}}
|
||||
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
|
||||
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={milestoneForm.handleSubmit((data) =>
|
||||
milestoneMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...data,
|
||||
} as any)
|
||||
)}
|
||||
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
@@ -792,10 +1033,29 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
<select
|
||||
{...milestoneForm.register('status')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMilestoneModalOpen(false)}
|
||||
onClick={() => {
|
||||
setIsMilestoneModalOpen(false);
|
||||
setEditingMilestone(null);
|
||||
}}
|
||||
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
@@ -805,7 +1065,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={milestoneMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
|
||||
>
|
||||
{milestoneMutation.isPending ? 'Saving...' : 'Define Milestone'}
|
||||
{milestoneMutation.isPending ? 'Saving...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
getUser,
|
||||
updateUserStatus,
|
||||
syncIntegration,
|
||||
syncUser,
|
||||
getUserSyncRecord,
|
||||
getInvites,
|
||||
sendInvite,
|
||||
getApplicantInvites,
|
||||
@@ -38,6 +40,7 @@ import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { getAppConfig } from '../config';
|
||||
import { triggerSessionExpired } from '../services/sessionExpiration';
|
||||
import {
|
||||
EmailStatuses,
|
||||
NotificationStatus,
|
||||
@@ -98,13 +101,13 @@ export const SettingsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => syncIntegration(),
|
||||
mutationFn: () => syncUser(undefined, true),
|
||||
onSuccess: () => {
|
||||
success('Integration Sync Complete', 'Background user and tenant directory synchronized successfully.');
|
||||
success('User Sync Complete', 'Authenticated user enabled and directory synced. Browser cache updated to prevent endpoint hotspot.');
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toastError('Sync Failed', err?.message || 'Failed to trigger background integration sync.');
|
||||
toastError('Sync Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,7 +122,7 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toastError('Update Failed', err?.message || 'Could not update user status.');
|
||||
toastError('Update Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,7 +173,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setInviteMessage('');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toastError('Dispatch Error', err?.message || 'Failed to dispatch invitation.');
|
||||
toastError('Dispatch Error', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,7 +186,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setApplicantInvitesResults(res);
|
||||
success('Lookup Complete', `Retrieved ${res.length} invitation records for applicant.`);
|
||||
} catch (err: any) {
|
||||
toastError('Lookup Error', err?.message || 'Applicant invitation lookup failed.');
|
||||
toastError('Lookup Error', err);
|
||||
setApplicantInvitesResults([]);
|
||||
} finally {
|
||||
setSearchingApplicant(false);
|
||||
@@ -224,14 +227,24 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === 'users' && (
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{syncMutation.isPending ? 'Syncing...' : 'Sync Integration'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
||||
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync'} (Synced in browser)` : 'Sync active in browser'}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Browser Sync Saved
|
||||
</span>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
|
||||
title="Force refresh user sync and update browser cache"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invites' && (
|
||||
@@ -366,8 +379,8 @@ export const SettingsPage: React.FC = () => {
|
||||
<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">
|
||||
<th className="py-3.5 px-5">User ID (UUID)</th>
|
||||
<th className="py-3.5 px-4">Email</th>
|
||||
<th className="py-3.5 px-5">User Email</th>
|
||||
<th className="py-3.5 px-4">User Identifier</th>
|
||||
<th className="py-3.5 px-4">Status</th>
|
||||
<th className="py-3.5 px-4">Last Login</th>
|
||||
<th className="py-3.5 px-5 text-right">Actions</th>
|
||||
@@ -391,11 +404,11 @@ export const SettingsPage: React.FC = () => {
|
||||
const badge = getActivityStatusBadge(u.status);
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||
{u.id}
|
||||
<td className="py-3.5 px-5 text-white font-medium">
|
||||
{u.email || getUserEmail(u.id) || <span className="text-slate-500 italic">No email recorded</span>}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-white font-medium">
|
||||
{u.email || <span className="text-slate-500 italic">No email recorded</span>}
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{u.id}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||
@@ -778,6 +791,31 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-amber-400" />
|
||||
Session Expiration vs Lockout Architecture (HTTP 401)
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5 max-w-2xl leading-relaxed">
|
||||
To disambiguate expired sessions from the session lockout process, the session expiration handler monitors all active business endpoints for HTTP 401 other than the user sync endpoint. The user sync endpoint remains dedicated to the session lockout & activation check, ensuring both features act in harmony where one is subordinated by call order and priority.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id="simulate-401-btn"
|
||||
onClick={() => {
|
||||
triggerSessionExpired('Simulated HTTP 401: Security token expired on business endpoint during active session test.');
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition-all shadow-md shrink-0"
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5 text-amber-400" />
|
||||
Simulate HTTP 401 Session Expiry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -939,12 +977,12 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User UUID:</span>
|
||||
<span className="font-mono text-white text-xs break-all">{selectedUserForDetail.id}</span>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
|
||||
<span className="text-white font-semibold text-xs">{selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Email:</span>
|
||||
<span className="text-white">{selectedUserForDetail.email || 'Not specified'}</span>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
|
||||
<span className="font-mono text-slate-300 text-xs break-all">{selectedUserForDetail.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user