Improved compliance monitoring
This commit is contained in:
@@ -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)."
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user