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';
@@ -14,6 +14,10 @@ import {
Flag,
X,
RotateCcw,
Target,
AlertTriangle,
ChevronRight,
ShieldAlert,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
@@ -23,8 +27,10 @@ import {
createActivity,
updateActivity,
getMilestones,
getProjectIndicators,
getBudgets,
} from '../../services/api';
import { Project, Activity, Milestone } from '../../types';
import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
import { formatDate, formatZAR } from '../../lib/formatters';
const activitySchema = z.object({
@@ -42,12 +48,14 @@ interface ProjectActivitiesModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
}
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
isOpen,
onClose,
project,
onOpenIndicatorsForActivity,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
@@ -73,6 +81,45 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
enabled: isOpen && !!projectId,
});
const { data: indicators = [] } = 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,
});
// Approved project budget calculations
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]);
const totalAllocated = useMemo(
() => activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0),
[activities]
);
const maxAllowedForForm = useMemo(() => {
if (approvedBudgetTotal <= 0) return Infinity;
const baseRemaining = approvedBudgetTotal - totalAllocated;
if (editingActivity) {
return baseRemaining + (Number(editingActivity.amount) || 0);
}
return Math.max(0, baseRemaining);
}, [approvedBudgetTotal, totalAllocated, editingActivity]);
const remainingBudget = useMemo(() => {
if (approvedBudgetTotal <= 0) return 0;
return Math.max(0, approvedBudgetTotal - totalAllocated);
}, [approvedBudgetTotal, totalAllocated]);
const form = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema),
defaultValues: {
@@ -114,6 +161,14 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
const mutation = useMutation({
mutationFn: async (data: ActivityFormValues): Promise<void> => {
if (!projectId) return;
const requestedAmount = Number(data.amount);
if (approvedBudgetTotal > 0 && requestedAmount > maxAllowedForForm) {
throw new Error(
`Activity amount (${formatZAR(requestedAmount)}) exceeds the available remaining project budget (${formatZAR(maxAllowedForForm)}).`
);
}
const parsedMilestoneId =
data.milestoneId && String(data.milestoneId).trim() !== ''
? Number(data.milestoneId)
@@ -127,7 +182,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
amount: requestedAmount,
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
@@ -137,7 +192,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
amount: requestedAmount,
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
@@ -145,6 +200,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
success(
editingActivity ? 'Activity Updated' : 'Activity Created',
@@ -163,8 +219,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
if (!project) return null;
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
return (
<Modal
isOpen={isOpen}
@@ -175,23 +229,52 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
}}
title={`Work Breakdown Activities: ${project.name}`}
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`}
helpText="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations."
helpText="Under each milestone, operational activities are scheduled with budgeted amounts. The sum of all activity budgets across the project cannot exceed the approved project budget."
maxWidth="xl"
>
<div className="space-y-5">
{/* KPI Summary 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 Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 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">
<Coins className="w-3.5 h-3.5 text-cyan-400" />
Total Allocated Work Budget
Total Allocated Activities
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span>
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
<span className="text-[11px] text-slate-400">({activities.length} tasks)</span>
</div>
</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 Project 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>
</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-blue-400" />
Remaining Budget Ceiling
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className={`text-xl font-bold font-mono ${remainingBudget <= 0 && approvedBudgetTotal > 0 ? 'text-rose-400' : 'text-blue-400'}`}>
{formatZAR(remainingBudget)}
</span>
</div>
</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">
Scheduled Tasks: <strong className="text-white">{activities.length}</strong>
</span>
<div className="flex items-center gap-2">
{!isFormOpen && (
<button
@@ -226,6 +309,13 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</button>
</div>
{approvedBudgetTotal > 0 && (
<div className="p-2.5 rounded-lg bg-blue-500/10 border border-blue-500/25 text-xs flex items-center justify-between text-blue-300">
<span>Maximum activity budget allowance:</span>
<strong className="font-mono">{formatZAR(maxAllowedForForm)}</strong>
</div>
)}
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
@@ -262,6 +352,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label>
<input
type="number"
step="0.01"
{...form.register('amount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]"
/>
@@ -350,7 +441,9 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Briefcase className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p>
<p className="text-[11px] text-slate-500">Deconstruct the project into operational tasks with allocated budgets.</p>
<p className="text-[11px] text-slate-500">
Deconstruct the project milestones into operational tasks with allocated budgets.
</p>
<button
type="button"
onClick={openCreateForm}
@@ -360,59 +453,119 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</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">
{activities.map((a) => {
const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null;
const actIndicators = indicators.filter((i) => String(i.activityId) === String(a.id));
const actActualCost = actIndicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
const isOverrun = actActualCost > Number(a.amount) && a.amount > 0;
return (
<div
key={a.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-start justify-between gap-3"
className={`p-3.5 rounded-xl border bg-[#0d121f] transition space-y-2.5 ${
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">{a.title}</span>
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium">
{a.category}
</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})` : ''}
<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">{a.title}</span>
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium">
{a.category}
</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>
)}
</div>
<p className="text-xs text-slate-400 leading-relaxed">{a.description}</p>
<div className="flex items-center gap-3 text-[11px] text-slate-500 font-mono pt-1">
<span>Date: {formatDate(a.performedAt)}</span>
<span>&bull;</span>
<span>ID #{a.id}</span>
</div>
</div>
<p className="text-xs text-slate-400 leading-relaxed">{a.description}</p>
<div className="flex items-center gap-3 text-[11px] text-slate-500 font-mono pt-1">
<span>Date: {formatDate(a.performedAt)}</span>
<span>&bull;</span>
<span>ID #{a.id}</span>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<div className="text-right mr-1">
<span className="block font-mono text-xs font-bold text-cyan-400">
{formatZAR(a.amount)}
</span>
{actActualCost > 0 && (
<span className={`block font-mono text-[10px] ${isOverrun ? 'text-rose-400 font-bold' : 'text-slate-400'}`}>
Actual: {formatZAR(actActualCost)}
</span>
)}
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-lg transition"
title="Inspect activity evidence"
>
<FileCheck className="w-3.5 h-3.5 text-cyan-400" />
Evidence
</Link>
<button
type="button"
onClick={() => openEditForm(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Activity"
>
<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">
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span>
{/* Linked Delivery Indicators Section */}
<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-2 flex-wrap">
<span className="text-[11px] text-slate-400 flex items-center gap-1">
<Target className="w-3 h-3 text-indigo-400" />
Delivery Specifications:
</span>
{actIndicators.length === 0 ? (
<span className="text-[11px] text-slate-500 italic">No indicators defined for this activity yet.</span>
) : (
<div className="flex items-center gap-1.5 flex-wrap">
{actIndicators.map((ind) => (
<span
key={ind.id}
className="px-2 py-0.5 rounded bg-slate-900 border border-slate-700/70 text-[10px] text-slate-300 font-mono"
>
{ind.name}: {ind.actualAmount}/{ind.targetAmount}
</span>
))}
</div>
)}
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-lg transition"
title="Inspect activity evidence"
>
<FileCheck className="w-3.5 h-3.5 text-cyan-400" />
Evidence
</Link>
<div className="flex items-center gap-2 shrink-0">
{isOverrun && (
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
</span>
)}
<button
type="button"
onClick={() => openEditForm(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Activity"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{onOpenIndicatorsForActivity && (
<button
type="button"
onClick={() => onOpenIndicatorsForActivity(a.id)}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
>
<span>Manage Indicators ({actIndicators.length})</span>
<ChevronRight className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);