Enforced relationships between activities and indicators, fixed actuals recording

This commit is contained in:
2026-10-22 13:47:54 +02:00
parent 4dd0899209
commit 9835121fd9
23 changed files with 3496 additions and 270 deletions
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,6 +16,10 @@ import {
X,
RotateCcw,
Lock,
Coins,
Briefcase,
Target,
AlertTriangle,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
@@ -24,11 +28,15 @@ import {
getMilestones,
createMilestone,
updateMilestone,
getActivities,
getProjectIndicators,
getBudgets,
} from '../../services/api';
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
import { Project, Milestone, Priority, ApprovalStatus, Activity, Indicator } from '../../types';
import { useRbac } from '../../auth/rbac';
import {
formatDate,
formatZAR,
getApprovalStatusBadge,
getPriorityBadge,
PRIORITY_OPTIONS,
@@ -66,7 +74,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
const {
data: milestones = [],
isLoading,
isLoading: isLoadingMilestones,
refetch,
} = useQuery({
queryKey: ['milestones', projectId],
@@ -74,6 +82,54 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
enabled: isOpen && !!projectId,
});
const {
data: activities = [],
isLoading: isLoadingActivities,
} = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const {
data: indicators = [],
isLoading: isLoadingIndicators,
} = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
enabled: isOpen,
});
// Calculate project approved budget
const approvedBudgetTotal = useMemo(() => {
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
return projectBudgets
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
}, [allBudgets, projectId]);
// Total project value is the sum total from all milestones (sum of their activities)
const totalProjectMilestonesValue = useMemo(() => {
return milestones.reduce((sum, m) => {
const mActs = activities.filter((a) => String(a.milestoneId) === String(m.id));
return sum + mActs.reduce((s, a) => s + (Number(a.amount) || 0), 0);
}, 0);
}, [milestones, activities]);
// Total project actuals reported by partner from indicators
const totalProjectActuals = useMemo(() => {
return indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
}, [indicators]);
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
const form = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema),
defaultValues: {
@@ -147,9 +203,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
if (!project) return null;
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
return (
<Modal
isOpen={isOpen}
@@ -160,23 +213,65 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}}
title={`Milestones & Deliverables: ${project.name}`}
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`}
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
helpText="Contractual milestones represent formal deliverables. The monetary value of each milestone is dynamically aggregated from its scheduled activities vs indicator actuals. The project's total value is the sum of all milestones."
maxWidth="xl"
>
<div className="space-y-5">
{/* Progress & Quick Stats Card */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
{/* KPI Cards: Project Value Roll-Up & Performance */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Completion Score
<Coins className="w-3.5 h-3.5 text-blue-400" />
Total Project Value
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{progressPct}%</span>
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-blue-400">{formatZAR(totalProjectMilestonesValue)}</span>
</div>
<span className="text-[10px] text-slate-500">Sum of all milestones</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-emerald-400" />
Approved Budget
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</span>
</div>
<span className="text-[10px] text-slate-500">Allocated budget ceiling</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-cyan-400" />
Partner Actuals
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalProjectActuals)}</span>
</div>
<span className="text-[10px] text-slate-500">Incurred material costs</span>
</div>
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
Milestone Progress
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold text-white font-mono">{progressPct}%</span>
</div>
<span className="text-[10px] text-slate-500">
{verifiedCount} of {milestones.length} verified
</span>
</div>
</div>
{/* Action Header */}
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
<span className="text-xs text-slate-400">
Deliverable Milestones: <strong className="text-white">{milestones.length}</strong>
</span>
<div className="flex items-center gap-2">
{!isFormOpen && canAdd('projects_milestones') && (
<button
@@ -257,40 +352,23 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
{editingMilestone && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
{Number(editingMilestone.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable)</span>
</div>
) : (
(() => {
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
const isSelf = isMaker(editingMilestone.createdBy);
return (
<select
{...form.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) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
let reasonText = '';
if (isApprovedOpt && !canApprove('projects_milestones')) {
reasonText = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
<select
{...form.register('status')}
disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
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 disabled:opacity-60 disabled:cursor-not-allowed"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
<p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
<Lock className="w-3 h-3" />
Maker-Checker: Cannot verify or approve a milestone that you authored.
</p>
)}
</div>
)}
@@ -331,7 +409,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button>
</div>
{isLoading ? (
{isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading milestones...
</div>
@@ -339,7 +417,9 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Flag className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No milestones registered yet</p>
<p className="text-[11px] text-slate-500">Define contractual deliverables to monitor implementation progress.</p>
<p className="text-[11px] text-slate-500">
Define contractual deliverables to monitor implementation progress.
</p>
<button
type="button"
onClick={openCreateForm}
@@ -349,55 +429,115 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button>
</div>
) : (
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
const isApproved = m.status === ApprovalStatus.Approved;
// Milestone activities
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
const plannedMilestoneValue = mActivities.reduce(
(sum, a) => sum + (Number(a.amount) || 0),
0
);
// Milestone indicators (linked to activities under this milestone)
const mActivityIds = new Set(mActivities.map((a) => String(a.id)));
const mIndicators = indicators.filter(
(i) => i.activityId && mActivityIds.has(String(i.activityId))
);
const actualMilestoneValue = mIndicators.reduce(
(sum, i) => sum + (Number(i.actualCost) || 0),
0
);
const isOverrun = actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0;
return (
<div
key={m.id}
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-center justify-between gap-3"
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold 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>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
{priBadge.label}
</span>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold 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>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
{priBadge.label}
</span>
{isOverrun && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
<AlertTriangle className="w-3 h-3 text-rose-400" />
Budget Overrun
</span>
)}
</div>
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span>&bull;</span>
<span>ID #{m.id}</span>
</div>
</div>
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span>&bull;</span>
<span>ID #{m.id}</span>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<div className="text-right mr-2">
<span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
<span className="font-mono text-xs font-bold text-blue-400 block">
{formatZAR(plannedMilestoneValue)}
</span>
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
title="Inspect verification evidence"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Evidence
</Link>
<button
type="button"
onClick={() => openEditForm(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Milestone"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
title="Inspect verification evidence"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Evidence
</Link>
{/* Breakdown of Activities & Indicators */}
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-3 text-slate-400 text-[11px] flex-wrap">
<span className="flex items-center gap-1">
<Briefcase className="w-3 h-3 text-cyan-400" />
Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
</span>
<span>&bull;</span>
<span className="flex items-center gap-1">
<Target className="w-3 h-3 text-indigo-400" />
Indicators: <strong className="text-white font-mono">{mIndicators.length}</strong>
</span>
<span>&bull;</span>
<span>
Actual Incurred: <strong className={`font-mono font-bold ${actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0 ? 'text-amber-400' : 'text-slate-200'}`}>{formatZAR(actualMilestoneValue)}</strong>
</span>
</div>
<button
type="button"
onClick={() => openEditForm(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Milestone"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{plannedMilestoneValue > 0 && (
<div className="text-[10px] text-slate-500 font-mono">
Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
</div>
)}
</div>
</div>
);