Merge pull request 'Enforced relationships between activities and indicators, fixed actuals recording' (#151) from finishline into main

Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
2026-10-22 13:49:32 +02:00
23 changed files with 3496 additions and 270 deletions
@@ -0,0 +1,36 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public class UpdateActivityEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPut("api/projects/activities", async (UpdateActivityRequest request,
HttpContext context, IProjectService service, CancellationToken cancellationToken = default) =>
await service.UpdateActivityAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.RequireAuthorization(Constants.MainScope)
.WithDescription("Update an existing activity under project (M04)")
.WithName(typeof(UpdateActivityEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
@@ -15,6 +15,10 @@ public interface IEvidenceService
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
@@ -28,6 +28,10 @@ public interface IProjectService
[Audit]
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -189,6 +189,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId, cancellationToken);
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateEvidenceAsync(request, userId, cancellationToken);
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
@@ -50,6 +50,9 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}));
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await RecordIndicatorActualAmountAsync(indicatorIdHash, amount, null, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
@@ -63,6 +66,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
.SetProperty(f => f.CreatedBy, userId), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator."));
@@ -130,14 +134,21 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New);
if (request.ActivityId.HasValue && !await context.Activities.AnyAsync(a => a.Id == request.ActivityId.Value && a.ProjectId == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.Create("Activity not found"));
context.Indicators.Add(new Domain.Entities.Indicator
{
ProjectId = request.ProjectId,
ActivityId = request.ActivityId,
Name = request.Name,
UnitOfMeasure = request.UnitOfMeasure,
BaselineAmount = request.BaselineAmount,
TargetAmount = request.TargetAmount,
ActualAmount = request.BaselineAmount,
TargetCost = request.TargetCost,
ActualCost = 0,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Evidence;
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
[Required] EvidenceType EvidenceType,
[Required] ApprovalStatus Status);
public sealed record RecordActualRequest([Required] decimal ActualAmount);
public sealed record RecordActualRequest([Required] decimal ActualAmount, decimal? ActualCost = null);
public sealed record CreateMilestoneRequest([Required] long ProjectId,
[Required] DateTime DueAt,
@@ -18,4 +18,6 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name,
[Required] UnitOfMeasure UnitOfMeasure,
[Required] decimal BaselineAmount,
[Required] decimal TargetAmount);
[Required] decimal TargetAmount,
long? ActivityId = null,
decimal TargetCost = 0);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -12,6 +12,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken);
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateActivityAsync(request, userId, cancellationToken);
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
@@ -123,6 +123,29 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.New);
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var existingTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
if (existingTotal + request.Amount > approvedBudget)
{
return Result.Fail(CreateError.Create($"Activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, already allocated: R {existingTotal:N2}, remaining: R {(approvedBudget - existingTotal):N2}."));
}
}
context.Activities.Add(new Domain.Entities.Activity
{
ProjectId = request.ProjectId,
@@ -141,6 +164,54 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreateError.Create("No record was created."));
}));
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var activity = await context.Activities.FirstOrDefaultAsync(a => a.Id == request.ActivityId, cancellationToken);
if (activity is null)
return Result.Fail(NotFoundError.Create("Activity not found"));
if (request.MilestoneId.HasValue && !await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.Create("Milestone not found"));
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var otherTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId && a.Id != request.ActivityId)
.SumAsync(a => a.Amount, cancellationToken);
if (otherTotal + request.Amount > approvedBudget)
{
return Result.Fail(UpdateError.Create($"Updated activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, other allocated: R {otherTotal:N2}, remaining: R {(approvedBudget - otherTotal):N2}."));
}
}
activity.Title = request.Title.Humanize(LetterCasing.Title).Trim();
activity.Description = request.Description;
activity.Category = request.Category.Humanize(LetterCasing.Title).Trim();
activity.Amount = request.Amount;
activity.MilestoneId = request.MilestoneId;
activity.PerformedAt = request.PerformedAt;
activity.UpdatedBy = userId;
activity.UpdatedAt = DateTime.UtcNow;
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
UpdateError.Create("No changes detected, update cancelled."));
}));
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Projects;
@@ -106,3 +106,12 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt);
public sealed record UpdateActivityRequest([Required] long ActivityId,
[Required] long ProjectId,
long? MilestoneId,
[Required][MaxLength(LabelLength)] string Category,
[Required] decimal Amount,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt);
@@ -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>
);
@@ -173,9 +173,14 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
{sBadge.label}
</span>
</div>
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
<div className="flex items-center justify-between text-[10px] 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 className="text-blue-400 font-bold">
{formatZAR(activities.filter(a => String(a.milestoneId) === String(m.id)).reduce((s, a) => s + (Number(a.amount) || 0), 0))}
</span>
</div>
</div>
);
@@ -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';
@@ -9,8 +9,12 @@ import {
TrendingUp,
X,
RotateCcw,
Check,
Edit3,
AlertTriangle,
Briefcase,
Filter,
Info,
ShieldCheck,
Coins,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
@@ -18,16 +22,18 @@ import { useToast } from '../ui/Toast';
import {
getProjectIndicators,
createIndicator,
recordIndicatorActual,
getActivities,
} from '../../services/api';
import { Project, Indicator, UnitOfMeasure } from '../../types';
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters';
import { Project, Indicator, UnitOfMeasure, Activity } from '../../types';
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters';
const indicatorSchema = z.object({
name: z.string().min(5, 'Indicator definition required (min 5 chars)'),
name: z.string().min(4, 'Indicator delivery specification required (min 4 chars)'),
activityId: z.string().optional().nullable(),
unitOfMeasure: z.coerce.number().min(1),
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
targetAmount: z.coerce.number().min(1, 'Target must be > 0'),
targetCost: z.coerce.number().min(0, 'Target cost must be >= 0').optional(),
});
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
@@ -36,25 +42,26 @@ interface ProjectIndicatorsModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
initialActivityId?: string | number | null;
}
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen,
onClose,
project,
initialActivityId = null,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false);
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
const projectId = project?.id;
const {
data: indicators = [],
isLoading,
isLoading: isLoadingIndicators,
refetch,
} = useQuery({
queryKey: ['indicators', projectId],
@@ -62,31 +69,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
enabled: isOpen && !!projectId,
});
const {
data: activities = [],
isLoading: isLoadingActivities,
} = useQuery({
queryKey: ['activities', projectId],
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema),
defaultValues: {
name: '',
activityId: initialActivityId ? String(initialActivityId) : '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
targetCost: 0,
},
});
const createMutation = useMutation({
mutationFn: async (data: IndicatorFormValues) => {
if (!projectId) return;
const parsedActivityId = data.activityId && String(data.activityId).trim() !== ''
? data.activityId
: null;
return createIndicator({
projectId: projectId,
activityId: parsedActivityId,
name: data.name,
unitOfMeasure: Number(data.unitOfMeasure),
baselineAmount: Number(data.baselineAmount),
targetAmount: Number(data.targetAmount),
targetCost: Number(data.targetCost) || 0,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Indicator Created', 'M&E Key Performance Indicator added to project');
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
success('Delivery Indicator Created', 'Indicator specification linked to work activity');
setIsFormOpen(false);
form.reset();
},
@@ -95,20 +120,22 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
},
});
const actualMutation = useMutation({
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => {
return recordIndicatorActual(indicatorId, actualAmount);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Actual Value Logged', 'M&E progress measurement updated successfully');
setRecordingActualForId(null);
},
onError: (err: unknown) => {
toastError('Failed to record actual value', err);
},
});
// Filter indicators by activity
const filteredIndicators = useMemo(() => {
if (selectedActivityFilter === 'all') return indicators;
if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId);
return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter));
}, [indicators, selectedActivityFilter]);
// Aggregate totals
const totalTargetCost = useMemo(
() => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0),
[indicators]
);
const totalActualCost = useMemo(
() => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0),
[indicators]
);
if (!project) return null;
@@ -117,34 +144,86 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen={isOpen}
onClose={() => {
setIsFormOpen(false);
setRecordingActualForId(null);
onClose();
}}
title={`M&E Performance Indicators: ${project.name}`}
subtitle={`Project #${project.id} • Results & Impact Framework`}
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)."
title={`M&E Delivery Indicators: ${project.name}`}
subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`}
helpText="Each indicator specifies an expected delivery output linked to an operational activity. Quantities and material costs are captured as actuals by the partner in the Partner Portal."
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">
<Target className="w-3.5 h-3.5 text-indigo-400" />
Monitoring & Evaluation Targets
Delivery Indicators
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{indicators.length}</span>
<span className="text-xs text-slate-400">Tracked performance metrics</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
<span className="text-[11px] text-slate-400">specifications</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-cyan-400" />
Target Delivery Cost
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalTargetCost)}</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">
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
Partner Actuals Incurred
</span>
<div className="flex items-baseline gap-2 mt-1">
<span className={`text-xl font-bold font-mono ${totalActualCost > totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}>
{formatZAR(totalActualCost)}
</span>
</div>
</div>
</div>
{/* Action Header & Activity Filter Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0d121f] p-3 rounded-xl border border-slate-800">
<div className="flex items-center gap-2 flex-1">
<Filter className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-xs text-slate-400 shrink-0">Filter by Activity:</span>
<select
value={selectedActivityFilter}
onChange={(e) => setSelectedActivityFilter(e.target.value)}
className="px-2.5 py-1 text-xs border border-slate-700/80 rounded-lg focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white max-w-xs truncate"
>
<option value="all">All Activities ({indicators.length})</option>
{activities.map((act) => {
const count = indicators.filter((i) => String(i.activityId) === String(act.id)).length;
return (
<option key={act.id} value={String(act.id)}>
{act.title} ({count} indicators)
</option>
);
})}
<option value="unlinked">Unlinked General Indicators</option>
</select>
</div>
<div className="flex items-center gap-2">
{!isFormOpen && (
<button
type="button"
onClick={() => {
form.reset();
form.reset({
name: '',
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
targetCost: 0,
});
setIsFormOpen(true);
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer"
@@ -156,13 +235,13 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</div>
</div>
{/* Create Indicator Form */}
{/* Indicator Create Form */}
{isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
<span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" />
Define New Key Performance Indicator (KPI)
Define Activity Delivery Indicator & Evidence Expectation
</span>
<button
type="button"
@@ -175,19 +254,41 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Indicator Name / Delivery Output Specification *
</label>
<input
type="text"
{...form.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white bg-[#090d16]"
placeholder="e.g. Number of Smallholder Farmers Trained in Precision Irrigation"
placeholder="e.g. Number of solar microgrids commissioned and tested"
/>
{form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Parent Activity (Required Delivery Link) *
</label>
<select
{...form.register('activityId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- Select Parent Activity --</option>
{activities.map((act) => (
<option key={act.id} value={String(act.id)}>
{act.title} (Budget: {formatZAR(act.amount)})
</option>
))}
</select>
<p className="text-[10px] text-slate-500 mt-1">
Linking to an activity establishes the causal delivery chain from planned task to substantiated output.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
<select
@@ -203,32 +304,47 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Expected Delivery Cost (Target Cost ZAR)
</label>
<input
type="number"
step="0.01"
{...form.register('targetCost')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
placeholder="0.00"
/>
<p className="text-[10px] text-slate-500 mt-1">Estimated monetary cost for delivering this indicator.</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Metric *</label>
<input
type="number"
step="any"
{...form.register('baselineAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Amount *</label>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Metric *</label>
<input
type="number"
step="any"
{...form.register('targetAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/>
{form.formState.errors.targetAmount && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.targetAmount.message}</p>
)}
</div>
</div>
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
className="px-3 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
@@ -237,17 +353,27 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
disabled={createMutation.isPending}
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer"
>
{createMutation.isPending ? 'Saving...' : 'Add Indicator'}
{createMutation.isPending ? 'Saving...' : 'Define Indicator'}
</button>
</div>
</form>
</div>
)}
{/* Notice Card Explaining Partner Action */}
<div className="bg-slate-900/60 p-3 rounded-xl border border-slate-800 flex items-start gap-2.5 text-slate-400 text-[11px] leading-relaxed">
<Info className="w-4 h-4 text-cyan-400 shrink-0 mt-0.5" />
<div>
<strong className="text-slate-200">Maker-Checker Operational Notice:</strong> ConsoleUI enables Project Managers to establish output specifications and expected delivery costs. Actual item quantities, material expenditures, and verification evidence documents are recorded by the Grantee/Partner via the <strong className="text-cyan-400 font-mono">PartnerUI Portal</strong>.
</div>
</div>
{/* Indicators List */}
<div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
<span>Performance Target Registry ({indicators.length})</span>
<span>
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
</span>
<button
type="button"
onClick={() => refetch()}
@@ -257,49 +383,77 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</button>
</div>
{isLoading ? (
{isLoadingIndicators || isLoadingActivities ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading indicators...
</div>
) : indicators.length === 0 ? (
) : filteredIndicators.length === 0 ? (
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Target className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No M&E indicators defined yet</p>
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p>
<p className="text-xs font-semibold text-slate-300">No indicators matching selection</p>
<p className="text-[11px] text-slate-500">
Define delivery output specifications and link them to operational activities.
</p>
<button
type="button"
onClick={() => setIsFormOpen(true)}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline"
>
<Plus className="w-3.5 h-3.5" /> Add first indicator
<Plus className="w-3.5 h-3.5" /> Define first indicator
</button>
</div>
) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
{indicators.map((ind) => {
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{filteredIndicators.map((ind) => {
const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0;
const baseline = Number(ind.baselineAmount) || 0;
const targetCost = Number(ind.targetCost) || 0;
const actualCost = Number(ind.actualCost) || 0;
const pct = Math.round((actual / target) * 100);
const isRecordingThis = recordingActualForId === ind.id;
const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId));
const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount);
return (
<div
key={ind.id}
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-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="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{ind.name}</span>
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
{getUnitOfMeasureLabel(ind.unitOfMeasure)}
</span>
{parentActivity ? (
<span className="bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 px-2 py-0.5 rounded-md text-[10px] flex items-center gap-1">
<Briefcase className="w-2.5 h-2.5" />
{parentActivity.title}
</span>
) : (
<span className="bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md text-[10px]">
General Project Indicator
</span>
)}
</div>
<div className="text-[10px] text-slate-500 font-mono">
Indicator ID #{ind.id}
{ind.activityId && ` • Activity #${ind.activityId}`}
</div>
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{isOverrun && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold">
<AlertTriangle className="w-3 h-3 text-rose-400" />
Cost Overrun
</span>
)}
<span
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
pct >= 100
@@ -309,34 +463,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
}`}
>
{pct}% Achieved
{pct}% Delivered
</span>
{!isRecordingThis ? (
<button
type="button"
onClick={() => {
setRecordingActualForId(ind.id);
setActualInput(actual);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/25 px-2.5 py-1 rounded-lg transition cursor-pointer"
>
<Edit3 className="w-3 h-3 text-indigo-400" />
Record Actual
</button>
) : (
<button
type="button"
onClick={() => setRecordingActualForId(null)}
className="text-slate-400 hover:text-slate-200 text-xs p-1"
>
<X className="w-4 h-4" />
</button>
)}
<span
className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded-md"
title="Actuals are recorded by the partner in PartnerUI"
>
<ShieldCheck className="w-3 h-3 text-cyan-400" />
Partner Verified
</span>
</div>
</div>
{/* Progress Bar */}
{/* Deliverable Units Progress Bar */}
<div className="space-y-1">
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
@@ -351,39 +491,44 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
/>
</div>
<div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span>
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span>
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span>
<span>
Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
</span>
<span>
Partner Actuals Reported:{' '}
<strong className="text-white font-mono font-bold">{actual}</strong>
</span>
<span>
Target: <strong className="text-indigo-400 font-mono">{target}</strong>
</span>
</div>
</div>
{/* Inline Record Actual Form */}
{isRecordingThis && (
<div className="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in">
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span>
<div className="flex items-center gap-2">
<input
type="number"
value={actualInput}
onChange={(e) => setActualInput(Number(e.target.value))}
className="w-24 px-2 py-1 text-xs border border-slate-700 rounded-lg text-white font-mono bg-[#090d16] focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
disabled={actualMutation.isPending}
onClick={() =>
actualMutation.mutate({
indicatorId: ind.id,
actualAmount: actualInput,
})
}
className="px-3 py-1 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg shadow-sm transition cursor-pointer"
>
{actualMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
{/* Financial Cost Specification & Actuals Row */}
<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-4 text-slate-400 text-[11px]">
<span>
Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
</span>
<span>
Actual Material Cost:{' '}
<strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
{formatZAR(actualCost)}
</strong>
</span>
{targetCost > 0 && (
<span className="text-slate-500">
Variance: {formatZAR(targetCost - actualCost)}
</span>
)}
</div>
)}
{isOverrun && (
<div className="text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Cost exceeds delivery allocation; subject to project variation approval (M15).
</div>
)}
</div>
</div>
);
})}
@@ -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>
);
@@ -195,6 +195,7 @@ export const ProjectsPage: React.FC = () => {
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
const [initialIndicatorActivityId, setInitialIndicatorActivityId] = useState<string | number | null>(null);
const [isRisksModalOpen, setIsRisksModalOpen] = useState(false);
// Queries
@@ -1726,6 +1727,11 @@ export const ProjectsPage: React.FC = () => {
setActiveModalProject(null);
}}
project={activeModalProject}
onOpenIndicatorsForActivity={(actId) => {
setInitialIndicatorActivityId(actId);
setIsActivitiesModalOpen(false);
setIsIndicatorsModalOpen(true);
}}
/>
{/* Indicators Management Modal */}
@@ -1734,8 +1740,10 @@ export const ProjectsPage: React.FC = () => {
onClose={() => {
setIsIndicatorsModalOpen(false);
setActiveModalProject(null);
setInitialIndicatorActivityId(null);
}}
project={activeModalProject}
initialActivityId={initialIndicatorActivityId}
/>
{/* Risks Management Modal */}
@@ -1216,10 +1216,12 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const res = await apiClient.post('/v1/api/evidence/indicators', {
projectId: toIntegerId(payload.projectId),
activityId: toNullableIntegerId(payload.activityId),
name: payload.name,
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
baselineAmount: Number(payload.baselineAmount) || 0,
targetAmount: Number(payload.targetAmount) || 0,
targetCost: Number(payload.targetCost) || 0,
});
return res.data;
};
@@ -742,21 +742,26 @@ export interface Indicator {
id: number | string;
tenantId?: number | string;
projectId: number | string;
activityId?: number | string | null;
name: string | null;
unitOfMeasure?: UnitOfMeasure | null;
baselineAmount: number;
targetAmount: number;
actualAmount: number;
targetCost?: number;
actualCost?: number;
createdBy: string;
createdAt: string;
}
export interface CreateIndicatorRequest {
projectId: number | string;
activityId?: number | string | null;
name: string;
unitOfMeasure: UnitOfMeasure;
baselineAmount: number;
targetAmount: number;
targetCost?: number;
}
export interface Risk {
@@ -18,6 +18,8 @@ public class Activity : Models.Activity, ITenantEntity
public virtual Milestone? Milestone { get; set; }
public virtual ICollection<Evidence> Evidences { get; set; } = [];
public virtual ICollection<Indicator> Indicators { get; set; } = [];
}
public sealed class ActivityConfiguration : IEntityTypeConfiguration<Activity>
@@ -9,6 +9,8 @@ public class Indicator : Models.Indicator, ITenantEntity
public virtual Project? Project { get; set; }
public virtual Activity? Activity { get; set; }
public virtual User? Creator { get; set; }
}
@@ -21,6 +23,7 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
builder.HasKey(pk => pk.Id);
builder.Property(f => f.TenantId).IsRequired();
builder.Property(f => f.ProjectId).IsRequired();
builder.Property(f => f.ActivityId).IsRequired(false);
builder.Property(f => f.CreatedBy).IsRequired();
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
builder.Property(f => f.Name).IsRequired().HasMaxLength(LabelLength);
@@ -28,6 +31,8 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
builder.Property(f => f.BaselineAmount).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.TargetAmount).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.ActualAmount).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.TargetCost).IsRequired().HasPrecision(18, 2).HasDefaultValueSql("0");
builder.Property(f => f.ActualCost).IsRequired().HasPrecision(18, 2).HasDefaultValueSql("0");
builder.HasOne(f => f.Tenant)
.WithMany()
@@ -41,10 +46,16 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(f => f.Activity)
.WithMany(a => a.Indicators)
.HasForeignKey(fk => fk.ActivityId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Creator)
.WithMany()
.HasForeignKey(fk => fk.CreatedBy)
.IsRequired()
.OnDelete(DeleteBehavior.NoAction);
}
}
}
@@ -201,7 +201,10 @@ public static class Mappers
CreatedBy = entity.CreatedBy,
Name = entity.Name,
ProjectId = entity.ProjectId,
ActivityId = entity.ActivityId,
TargetAmount = entity.TargetAmount,
TargetCost = entity.TargetCost,
ActualCost = entity.ActualCost,
UnitOfMeasure = entity.UnitOfMeasure
};
@@ -8,6 +8,8 @@ public class Indicator
public long ProjectId { get; set; }
public long? ActivityId { get; set; }
public Guid CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
@@ -21,4 +23,8 @@ public class Indicator
public decimal TargetAmount { get; set; }
public decimal ActualAmount { get; set; }
}
public decimal TargetCost { get; set; }
public decimal ActualCost { get; set; }
}
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class AddedActivityToIndicator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "ActivityId",
table: "Indicators",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ActualCost",
table: "Indicators",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValueSql: "0");
migrationBuilder.AddColumn<decimal>(
name: "TargetCost",
table: "Indicators",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValueSql: "0");
migrationBuilder.CreateIndex(
name: "IX_Indicators_ActivityId",
table: "Indicators",
column: "ActivityId");
migrationBuilder.AddForeignKey(
name: "FK_Indicators_Activities_ActivityId",
table: "Indicators",
column: "ActivityId",
principalTable: "Activities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Indicators_Activities_ActivityId",
table: "Indicators");
migrationBuilder.DropIndex(
name: "IX_Indicators_ActivityId",
table: "Indicators");
migrationBuilder.DropColumn(
name: "ActivityId",
table: "Indicators");
migrationBuilder.DropColumn(
name: "ActualCost",
table: "Indicators");
migrationBuilder.DropColumn(
name: "TargetCost",
table: "Indicators");
}
}
}
@@ -634,10 +634,19 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("ActivityId")
.HasColumnType("bigint");
b.Property<decimal>("ActualAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("ActualCost")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasDefaultValueSql("0");
b.Property<decimal>("BaselineAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
@@ -662,6 +671,12 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("TargetCost")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasDefaultValueSql("0");
b.Property<long>("TenantId")
.HasColumnType("bigint");
@@ -672,6 +687,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.HasKey("Id");
b.HasIndex("ActivityId");
b.HasIndex("CreatedBy");
b.HasIndex("ProjectId");
@@ -1929,6 +1946,11 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
modelBuilder.Entity("PostFundManagement.Domain.Entities.Indicator", b =>
{
b.HasOne("PostFundManagement.Domain.Entities.Activity", "Activity")
.WithMany("Indicators")
.HasForeignKey("ActivityId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("PostFundManagement.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -1947,6 +1969,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Activity");
b.Navigation("Creator");
b.Navigation("Project");
@@ -2440,6 +2464,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b =>
{
b.Navigation("Evidences");
b.Navigation("Indicators");
});
modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b =>