Improved compliance monitoring

This commit is contained in:
2026-10-22 15:42:36 +02:00
parent 9835121fd9
commit 299d6ff4dc
27 changed files with 4245 additions and 149 deletions
@@ -29,6 +29,7 @@ import {
Filter,
FileText,
Activity,
ShieldCheck,
} from 'lucide-react';
import {
getProjects,
@@ -40,6 +41,7 @@ import {
createIndicator,
recordIndicatorActual,
getBudgets,
getActivities,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -73,6 +75,7 @@ import {
getUserEmail,
} from '../lib/formatters';
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
@@ -192,6 +195,8 @@ export const ProjectsPage: React.FC = () => {
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [activeModalProject, setActiveModalProject] = useState<Project | null>(null);
const [selectedProjectForCompliance, setSelectedProjectForCompliance] = useState<Project | null>(null);
const [isComplianceModalOpen, setIsComplianceModalOpen] = useState(false);
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
@@ -237,6 +242,31 @@ export const ProjectsPage: React.FC = () => {
queryFn: () => getBudgets(1, 100),
});
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
queryFn: async () => {
const map = new Map<string | number, any[]>();
await Promise.all(
projects.map(async (p) => {
try {
const acts = await getActivities(p.id);
map.set(p.id, acts);
} catch {
map.set(p.id, []);
}
})
);
return map;
},
enabled: projects.length > 0,
});
const handleOpenComplianceStatus = (p: Project, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedProjectForCompliance(p);
setIsComplianceModalOpen(true);
};
// Programme lookup map
const programmeMap = useMemo(() => {
const map = new Map<string | number, Programme>();
@@ -977,13 +1007,24 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">Actions</th>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
<div className="flex items-center justify-end gap-1">
<span>Project Variance</span>
<HelpTooltip
title="Project Variance"
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
size="sm"
placement="bottom"
/>
</div>
</th>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-medium">
{loadingProjects ? (
<tr>
<td colSpan={6} className="p-12 text-center text-slate-400 text-xs">
<td colSpan={7} className="p-12 text-center text-slate-400 text-xs">
<div className="flex flex-col items-center justify-center space-y-2">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading project directory...</span>
@@ -992,7 +1033,7 @@ export const ProjectsPage: React.FC = () => {
</tr>
) : sortedProjects.length === 0 ? (
<tr>
<td colSpan={6} className="p-12 text-center text-slate-400">
<td colSpan={7} className="p-12 text-center text-slate-400">
<div className="flex flex-col items-center justify-center space-y-3">
<FolderKanban className="w-10 h-10 text-slate-600" />
<p className="text-sm font-semibold text-slate-300">No matching projects found</p>
@@ -1166,17 +1207,51 @@ export const ProjectsPage: React.FC = () => {
</div>
</td>
{/* Row Action: View Project (Opens Large WBS & Gantt Console) */}
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={(e) => handleOpenProjectView(p, e)}
className="inline-flex items-center gap-1.5 px-3.5 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-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
title="Open Project View & Interactive Gantt Timeline"
>
<Eye className="w-3.5 h-3.5" />
<span>View Project</span>
</button>
{/* Project Variance Column */}
{(() => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pVariance = approvedBudget - pActsTotal;
const isPos = pVariance >= 0;
return (
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs border ${
isPos
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
>
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
</span>
</td>
);
})()}
{/* Row Action: View Project & Compliance Status */}
<td className="py-3.5 px-4 text-right whitespace-nowrap w-56" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={(e) => handleOpenProjectView(p, e)}
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-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
title="Open Project View & Interactive Gantt Timeline"
>
<Eye className="w-3.5 h-3.5" />
<span>View Project</span>
</button>
<button
type="button"
onClick={(e) => handleOpenComplianceStatus(p, e)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 rounded-xl transition cursor-pointer shadow-sm active:scale-95"
title="View Compliance Status, Risks & Beneficiaries"
>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Compliance Status</span>
</button>
</div>
</td>
</tr>
);
@@ -1367,7 +1442,7 @@ export const ProjectsPage: React.FC = () => {
<tbody className="divide-y divide-slate-800/50">
{sortedIndicatorsData.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-slate-500">
<td colSpan={7} className="py-12 text-center text-slate-500">
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all'
? 'No indicators match your filter criteria.'
@@ -1389,10 +1464,11 @@ export const ProjectsPage: React.FC = () => {
setSelectedIndicatorForActual(ind);
setActualInput(ind.actualAmount);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-600 px-3 py-1.5 rounded-lg border border-blue-500/25 transition cursor-pointer"
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
title="Edit actual realized value in place"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Record Actual</span>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
<span>Edit Actual</span>
</button>
</td>
</tr>
@@ -1657,6 +1733,16 @@ export const ProjectsPage: React.FC = () => {
</div>
)}
{/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */}
<ProjectComplianceModal
isOpen={isComplianceModalOpen}
onClose={() => {
setIsComplianceModalOpen(false);
setSelectedProjectForCompliance(null);
}}
project={selectedProjectForCompliance}
programme={selectedProjectForCompliance ? programmeMap.get(selectedProjectForCompliance.programmeId) || null : null}
/>
<ProjectViewModal
isOpen={isProjectViewModalOpen}
onClose={() => {
@@ -1886,7 +1972,7 @@ export const ProjectsPage: React.FC = () => {
<Modal
isOpen={!!selectedIndicatorForActual}
onClose={() => setSelectedIndicatorForActual(null)}
title="Record Indicator Actual Value"
title="Edit Indicator Actual Value"
subtitle={selectedIndicatorForActual?.name || ''}
helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating (BR-009)."
>