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;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -15,6 +15,10 @@ public interface IEvidenceService
[Audit] [Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default); 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] [Log]
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default); 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;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -28,6 +28,10 @@ public interface IProjectService
[Audit] [Audit]
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default); Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log] [Log]
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default); 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.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; 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) => public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken); 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) => public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateEvidenceAsync(request, userId, cancellationToken); 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.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; 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) => 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 () => await Result.Try((Func<Task<Result>>)(async () =>
{ {
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash); 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) var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount) .SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
.SetProperty(f => f.CreatedBy, userId), cancellationToken); .SetProperty(f => f.CreatedBy, userId), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator.")); 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)) if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New); 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 context.Indicators.Add(new Domain.Entities.Indicator
{ {
ProjectId = request.ProjectId, ProjectId = request.ProjectId,
ActivityId = request.ActivityId,
Name = request.Name, Name = request.Name,
UnitOfMeasure = request.UnitOfMeasure, UnitOfMeasure = request.UnitOfMeasure,
BaselineAmount = request.BaselineAmount, BaselineAmount = request.BaselineAmount,
TargetAmount = request.TargetAmount, TargetAmount = request.TargetAmount,
ActualAmount = request.BaselineAmount, ActualAmount = request.BaselineAmount,
TargetCost = request.TargetCost,
ActualCost = 0,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
[Required] EvidenceType EvidenceType, [Required] EvidenceType EvidenceType,
[Required] ApprovalStatus Status); [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, public sealed record CreateMilestoneRequest([Required] long ProjectId,
[Required] DateTime DueAt, [Required] DateTime DueAt,
@@ -18,4 +18,6 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string Name,
[Required] UnitOfMeasure UnitOfMeasure, [Required] UnitOfMeasure UnitOfMeasure,
[Required] decimal BaselineAmount, [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.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; 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) => public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken); 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) => public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, userId, cancellationToken); 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.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions; 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)) if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.New); 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 context.Activities.Add(new Domain.Entities.Activity
{ {
ProjectId = request.ProjectId, ProjectId = request.ProjectId,
@@ -141,6 +164,54 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreateError.Create("No record was created.")); 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) => public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () => await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{ {
@@ -1,4 +1,4 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Projects; namespace PostFundManagement.Application.Services.Projects;
@@ -106,3 +106,12 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title, [Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description, [Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt); 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -14,6 +14,10 @@ import {
Flag, Flag,
X, X,
RotateCcw, RotateCcw,
Target,
AlertTriangle,
ChevronRight,
ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -23,8 +27,10 @@ import {
createActivity, createActivity,
updateActivity, updateActivity,
getMilestones, getMilestones,
getProjectIndicators,
getBudgets,
} from '../../services/api'; } from '../../services/api';
import { Project, Activity, Milestone } from '../../types'; import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
import { formatDate, formatZAR } from '../../lib/formatters'; import { formatDate, formatZAR } from '../../lib/formatters';
const activitySchema = z.object({ const activitySchema = z.object({
@@ -42,12 +48,14 @@ interface ProjectActivitiesModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
project: Project | null; project: Project | null;
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
} }
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
isOpen, isOpen,
onClose, onClose,
project, project,
onOpenIndicatorsForActivity,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
@@ -73,6 +81,45 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
enabled: isOpen && !!projectId, 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>({ const form = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema), resolver: zodResolver(activitySchema),
defaultValues: { defaultValues: {
@@ -114,6 +161,14 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: ActivityFormValues): Promise<void> => { mutationFn: async (data: ActivityFormValues): Promise<void> => {
if (!projectId) return; 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 = const parsedMilestoneId =
data.milestoneId && String(data.milestoneId).trim() !== '' data.milestoneId && String(data.milestoneId).trim() !== ''
? Number(data.milestoneId) ? Number(data.milestoneId)
@@ -127,7 +182,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title, title: data.title,
description: data.description, description: data.description,
category: data.category, category: data.category,
amount: Number(data.amount), amount: requestedAmount,
milestoneId: parsedMilestoneId, milestoneId: parsedMilestoneId,
performedAt: performedAtIso, performedAt: performedAtIso,
}); });
@@ -137,7 +192,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
title: data.title, title: data.title,
description: data.description, description: data.description,
category: data.category, category: data.category,
amount: Number(data.amount), amount: requestedAmount,
milestoneId: parsedMilestoneId, milestoneId: parsedMilestoneId,
performedAt: performedAtIso, performedAt: performedAtIso,
}); });
@@ -145,6 +200,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', projectId] }); queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
success( success(
editingActivity ? 'Activity Updated' : 'Activity Created', editingActivity ? 'Activity Updated' : 'Activity Created',
@@ -163,8 +219,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
if (!project) return null; if (!project) return null;
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -175,23 +229,52 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
}} }}
title={`Work Breakdown Activities: ${project.name}`} title={`Work Breakdown Activities: ${project.name}`}
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`} 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" maxWidth="xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* KPI Summary Card */} {/* KPI Summary Cards */}
<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="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1"> <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"> <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" /> <Coins className="w-3.5 h-3.5 text-cyan-400" />
Total Allocated Work Budget Total Allocated Activities
</span> </span>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span> <span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span> <span className="text-[11px] text-slate-400">({activities.length} tasks)</span>
</div> </div>
</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"> <div className="flex items-center gap-2">
{!isFormOpen && ( {!isFormOpen && (
<button <button
@@ -226,6 +309,13 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</button> </button>
</div> </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"> <form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label> <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> <label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label>
<input <input
type="number" type="number"
step="0.01"
{...form.register('amount')} {...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]" 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"> <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" /> <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-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 <button
type="button" type="button"
onClick={openCreateForm} onClick={openCreateForm}
@@ -360,59 +453,119 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
</button> </button>
</div> </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) => { {activities.map((a) => {
const linkedMilestone = a.milestoneId const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId)) ? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null; : 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 ( return (
<div <div
key={a.id} 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 flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="space-y-1">
<span className="text-xs font-bold text-white">{a.title}</span> <div className="flex items-center gap-2 flex-wrap">
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium"> <span className="text-xs font-bold text-white">{a.title}</span>
{a.category} <span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium">
</span> {a.category}
{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> </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> </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"> <div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<span>Date: {formatDate(a.performedAt)}</span> <div className="text-right mr-1">
<span>&bull;</span> <span className="block font-mono text-xs font-bold text-cyan-400">
<span>ID #{a.id}</span> {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> </div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center"> {/* Linked Delivery Indicators Section */}
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span> <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 <div className="flex items-center gap-2 shrink-0">
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`} {isOverrun && (
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" <span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
title="Inspect activity evidence" ⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
> </span>
<FileCheck className="w-3.5 h-3.5 text-cyan-400" /> )}
Evidence
</Link>
<button {onOpenIndicatorsForActivity && (
type="button" <button
onClick={() => openEditForm(a)} type="button"
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer" onClick={() => onOpenIndicatorsForActivity(a.id)}
title="Edit Activity" className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
> >
<Edit2 className="w-3.5 h-3.5" /> <span>Manage Indicators ({actIndicators.length})</span>
</button> <ChevronRight className="w-3 h-3" />
</button>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -173,9 +173,14 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
{sBadge.label} {sBadge.label}
</span> </span>
</div> </div>
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono"> <div className="flex items-center justify-between text-[10px] text-slate-400 font-mono">
<Clock className="w-3 h-3 text-slate-500" /> <span className="flex items-center gap-1">
Due: {formatDate(m.dueAt)} <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>
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -9,8 +9,12 @@ import {
TrendingUp, TrendingUp,
X, X,
RotateCcw, RotateCcw,
Check, AlertTriangle,
Edit3, Briefcase,
Filter,
Info,
ShieldCheck,
Coins,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -18,16 +22,18 @@ import { useToast } from '../ui/Toast';
import { import {
getProjectIndicators, getProjectIndicators,
createIndicator, createIndicator,
recordIndicatorActual, getActivities,
} from '../../services/api'; } from '../../services/api';
import { Project, Indicator, UnitOfMeasure } from '../../types'; import { Project, Indicator, UnitOfMeasure, Activity } from '../../types';
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters'; import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters';
const indicatorSchema = z.object({ 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), unitOfMeasure: z.coerce.number().min(1),
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'), baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
targetAmount: z.coerce.number().min(1, 'Target 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>; type IndicatorFormValues = z.infer<typeof indicatorSchema>;
@@ -36,25 +42,26 @@ interface ProjectIndicatorsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
project: Project | null; project: Project | null;
initialActivityId?: string | number | null;
} }
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen, isOpen,
onClose, onClose,
project, project,
initialActivityId = null,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null); const [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
const [actualInput, setActualInput] = useState<number>(0);
const projectId = project?.id; const projectId = project?.id;
const { const {
data: indicators = [], data: indicators = [],
isLoading, isLoading: isLoadingIndicators,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ['indicators', projectId], queryKey: ['indicators', projectId],
@@ -62,31 +69,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
enabled: isOpen && !!projectId, 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>({ const form = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema), resolver: zodResolver(indicatorSchema),
defaultValues: { defaultValues: {
name: '', name: '',
activityId: initialActivityId ? String(initialActivityId) : '',
unitOfMeasure: UnitOfMeasure.Count, unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0, baselineAmount: 0,
targetAmount: 100, targetAmount: 100,
targetCost: 0,
}, },
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: IndicatorFormValues) => { mutationFn: async (data: IndicatorFormValues) => {
if (!projectId) return; if (!projectId) return;
const parsedActivityId = data.activityId && String(data.activityId).trim() !== ''
? data.activityId
: null;
return createIndicator({ return createIndicator({
projectId: projectId, projectId: projectId,
activityId: parsedActivityId,
name: data.name, name: data.name,
unitOfMeasure: Number(data.unitOfMeasure), unitOfMeasure: Number(data.unitOfMeasure),
baselineAmount: Number(data.baselineAmount), baselineAmount: Number(data.baselineAmount),
targetAmount: Number(data.targetAmount), targetAmount: Number(data.targetAmount),
targetCost: Number(data.targetCost) || 0,
}); });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] }); queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] }); 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); setIsFormOpen(false);
form.reset(); form.reset();
}, },
@@ -95,20 +120,22 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
}, },
}); });
const actualMutation = useMutation({ // Filter indicators by activity
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => { const filteredIndicators = useMemo(() => {
return recordIndicatorActual(indicatorId, actualAmount); if (selectedActivityFilter === 'all') return indicators;
}, if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId);
onSuccess: () => { return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter));
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] }); }, [indicators, selectedActivityFilter]);
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Actual Value Logged', 'M&E progress measurement updated successfully'); // Aggregate totals
setRecordingActualForId(null); const totalTargetCost = useMemo(
}, () => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0),
onError: (err: unknown) => { [indicators]
toastError('Failed to record actual value', err); );
}, const totalActualCost = useMemo(
}); () => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0),
[indicators]
);
if (!project) return null; if (!project) return null;
@@ -117,34 +144,86 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen={isOpen} isOpen={isOpen}
onClose={() => { onClose={() => {
setIsFormOpen(false); setIsFormOpen(false);
setRecordingActualForId(null);
onClose(); onClose();
}} }}
title={`M&E Performance Indicators: ${project.name}`} title={`M&E Delivery Indicators: ${project.name}`}
subtitle={`Project #${project.id} • Results & Impact Framework`} subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`}
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)." 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" maxWidth="xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* KPI Summary Card */} {/* KPI Summary Cards */}
<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="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1"> <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"> <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" /> <Target className="w-3.5 h-3.5 text-indigo-400" />
Monitoring & Evaluation Targets Delivery Indicators
</span> </span>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold text-white">{indicators.length}</span> <span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
<span className="text-xs text-slate-400">Tracked performance metrics</span> <span className="text-[11px] text-slate-400">specifications</span>
</div> </div>
</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"> <div className="flex items-center gap-2">
{!isFormOpen && ( {!isFormOpen && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
form.reset(); form.reset({
name: '',
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
targetCost: 0,
});
setIsFormOpen(true); 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" 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>
</div> </div>
{/* Create Indicator Form */} {/* Indicator Create Form */}
{isFormOpen && ( {isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in"> <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"> <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"> <span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" /> <Target className="w-3.5 h-3.5" />
Define New Key Performance Indicator (KPI) Define Activity Delivery Indicator & Evidence Expectation
</span> </span>
<button <button
type="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"> <form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
<div> <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 <input
type="text" type="text"
{...form.register('name')} {...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]" 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 && ( {form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p> <p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)} )}
</div> </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> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
<select <select
@@ -203,32 +304,47 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</div> </div>
<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 <input
type="number" 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')} {...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]" 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>
<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 <input
type="number" type="number"
step="any"
{...form.register('targetAmount')} {...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]" 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> </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 <button
type="button" type="button"
onClick={() => setIsFormOpen(false)} 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 Cancel
</button> </button>
@@ -237,17 +353,27 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
disabled={createMutation.isPending} 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" 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> </button>
</div> </div>
</form> </form>
</div> </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 */} {/* Indicators List */}
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1"> <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 <button
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
@@ -257,49 +383,77 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
</button> </button>
</div> </div>
{isLoading ? ( {isLoadingIndicators || isLoadingActivities ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800"> <div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading indicators... Loading indicators...
</div> </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"> <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" /> <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-xs font-semibold text-slate-300">No indicators matching selection</p>
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p> <p className="text-[11px] text-slate-500">
Define delivery output specifications and link them to operational activities.
</p>
<button <button
type="button" type="button"
onClick={() => setIsFormOpen(true)} onClick={() => setIsFormOpen(true)}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline" 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> </button>
</div> </div>
) : ( ) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
{indicators.map((ind) => { {filteredIndicators.map((ind) => {
const target = Number(ind.targetAmount) || 1; const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0; const actual = Number(ind.actualAmount) || 0;
const baseline = Number(ind.baselineAmount) || 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 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 ( return (
<div <div
key={ind.id} 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 className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div> <div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{ind.name}</span> <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]"> <span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
{getUnitOfMeasureLabel(ind.unitOfMeasure)} {getUnitOfMeasureLabel(ind.unitOfMeasure)}
</span> </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> </div>
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
</div> </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 <span
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${ className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
pct >= 100 pct >= 100
@@ -309,34 +463,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
: 'bg-amber-500/10 text-amber-400 border-amber-500/30' : 'bg-amber-500/10 text-amber-400 border-amber-500/30'
}`} }`}
> >
{pct}% Achieved {pct}% Delivered
</span> </span>
{!isRecordingThis ? ( <span
<button 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"
type="button" title="Actuals are recorded by the partner in PartnerUI"
onClick={() => { >
setRecordingActualForId(ind.id); <ShieldCheck className="w-3 h-3 text-cyan-400" />
setActualInput(actual); Partner Verified
}} </span>
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>
)}
</div> </div>
</div> </div>
{/* Progress Bar */} {/* Deliverable Units Progress Bar */}
<div className="space-y-1"> <div className="space-y-1">
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800"> <div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div <div
@@ -351,39 +491,44 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
/> />
</div> </div>
<div className="flex justify-between text-[11px] text-slate-400"> <div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span> <span>
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span> Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span> </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>
</div> </div>
{/* Inline Record Actual Form */} {/* Financial Cost Specification & Actuals Row */}
{isRecordingThis && ( <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="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in"> <div className="flex items-center gap-4 text-slate-400 text-[11px]">
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span> <span>
<div className="flex items-center gap-2"> Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
<input </span>
type="number" <span>
value={actualInput} Actual Material Cost:{' '}
onChange={(e) => setActualInput(Number(e.target.value))} <strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
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" {formatZAR(actualCost)}
/> </strong>
<button </span>
type="button" {targetCost > 0 && (
disabled={actualMutation.isPending} <span className="text-slate-500">
onClick={() => Variance: {formatZAR(targetCost - actualCost)}
actualMutation.mutate({ </span>
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>
</div> </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> </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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,6 +16,10 @@ import {
X, X,
RotateCcw, RotateCcw,
Lock, Lock,
Coins,
Briefcase,
Target,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
@@ -24,11 +28,15 @@ import {
getMilestones, getMilestones,
createMilestone, createMilestone,
updateMilestone, updateMilestone,
getActivities,
getProjectIndicators,
getBudgets,
} from '../../services/api'; } 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 { useRbac } from '../../auth/rbac';
import { import {
formatDate, formatDate,
formatZAR,
getApprovalStatusBadge, getApprovalStatusBadge,
getPriorityBadge, getPriorityBadge,
PRIORITY_OPTIONS, PRIORITY_OPTIONS,
@@ -66,7 +74,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
const { const {
data: milestones = [], data: milestones = [],
isLoading, isLoading: isLoadingMilestones,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ['milestones', projectId], queryKey: ['milestones', projectId],
@@ -74,6 +82,54 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
enabled: isOpen && !!projectId, 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>({ const form = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema), resolver: zodResolver(milestoneSchema),
defaultValues: { defaultValues: {
@@ -147,9 +203,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
if (!project) return null; 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 ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -160,23 +213,65 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}} }}
title={`Milestones & Deliverables: ${project.name}`} title={`Milestones & Deliverables: ${project.name}`}
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`} 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" maxWidth="xl"
> >
<div className="space-y-5"> <div className="space-y-5">
{/* Progress & Quick Stats Card */} {/* KPI Cards: Project Value Roll-Up & Performance */}
<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="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div className="space-y-1"> <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"> <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" /> <Coins className="w-3.5 h-3.5 text-blue-400" />
Calculated Completion Score Total Project Value
</span> </span>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold text-white">{progressPct}%</span> <span className="text-xl font-bold font-mono text-blue-400">{formatZAR(totalProjectMilestonesValue)}</span>
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
</div> </div>
<span className="text-[10px] text-slate-500">Sum of all milestones</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 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"> <div className="flex items-center gap-2">
{!isFormOpen && canAdd('projects_milestones') && ( {!isFormOpen && canAdd('projects_milestones') && (
<button <button
@@ -257,40 +352,23 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
{editingMilestone && ( {editingMilestone && (
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label> <label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
{Number(editingMilestone.status) === ApprovalStatus.Approved ? ( <select
<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"> {...form.register('status')}
<Lock className="w-3.5 h-3.5 text-emerald-400" /> disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
<span>Approved &amp; Verified (Immutable)</span> 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"
</div> >
) : ( {APPROVAL_STATUS_OPTIONS.map((opt) => (
(() => { <option key={opt.value} value={opt.value}>
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy); {opt.label}
const isSelf = isMaker(editingMilestone.createdBy); </option>
))}
return ( </select>
<select {editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
{...form.register('status')} <p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
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" <Lock className="w-3 h-3" />
> Maker-Checker: Cannot verify or approve a milestone that you authored.
{APPROVAL_STATUS_OPTIONS.map((opt) => { </p>
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>
);
})()
)} )}
</div> </div>
)} )}
@@ -331,7 +409,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button> </button>
</div> </div>
{isLoading ? ( {isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800"> <div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading milestones... Loading milestones...
</div> </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"> <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" /> <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-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 <button
type="button" type="button"
onClick={openCreateForm} onClick={openCreateForm}
@@ -349,55 +429,115 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</button> </button>
</div> </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) => { {milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status); const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority); 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 ( return (
<div <div
key={m.id} 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 flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="space-y-1">
<span className="text-xs font-bold text-white">{m.name}</span> <div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}> <span className="text-xs font-bold text-white">{m.name}</span>
{statusBadge.label} <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
</span> {statusBadge.label}
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}> </span>
{priBadge.label} <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
</span> {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>
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
<span className="flex items-center gap-1"> <div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<Clock className="w-3 h-3 text-slate-500" /> <div className="text-right mr-2">
Due: {formatDate(m.dueAt)} <span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
</span> <span className="font-mono text-xs font-bold text-blue-400 block">
<span>&bull;</span> {formatZAR(plannedMilestoneValue)}
<span>ID #{m.id}</span> </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> </div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center"> {/* Breakdown of Activities & Indicators */}
<Link <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">
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`} <div className="flex items-center gap-3 text-slate-400 text-[11px] flex-wrap">
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" <span className="flex items-center gap-1">
title="Inspect verification evidence" <Briefcase className="w-3 h-3 text-cyan-400" />
> Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
<FileCheck className="w-3.5 h-3.5 text-blue-400" /> </span>
Evidence <span>&bull;</span>
</Link> <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 {plannedMilestoneValue > 0 && (
type="button" <div className="text-[10px] text-slate-500 font-mono">
onClick={() => openEditForm(m)} Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer" </div>
title="Edit Milestone" )}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
); );
@@ -195,6 +195,7 @@ export const ProjectsPage: React.FC = () => {
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false); const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false); const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false); const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
const [initialIndicatorActivityId, setInitialIndicatorActivityId] = useState<string | number | null>(null);
const [isRisksModalOpen, setIsRisksModalOpen] = useState(false); const [isRisksModalOpen, setIsRisksModalOpen] = useState(false);
// Queries // Queries
@@ -1726,6 +1727,11 @@ export const ProjectsPage: React.FC = () => {
setActiveModalProject(null); setActiveModalProject(null);
}} }}
project={activeModalProject} project={activeModalProject}
onOpenIndicatorsForActivity={(actId) => {
setInitialIndicatorActivityId(actId);
setIsActivitiesModalOpen(false);
setIsIndicatorsModalOpen(true);
}}
/> />
{/* Indicators Management Modal */} {/* Indicators Management Modal */}
@@ -1734,8 +1740,10 @@ export const ProjectsPage: React.FC = () => {
onClose={() => { onClose={() => {
setIsIndicatorsModalOpen(false); setIsIndicatorsModalOpen(false);
setActiveModalProject(null); setActiveModalProject(null);
setInitialIndicatorActivityId(null);
}} }}
project={activeModalProject} project={activeModalProject}
initialActivityId={initialIndicatorActivityId}
/> />
{/* Risks Management Modal */} {/* Risks Management Modal */}
@@ -1216,10 +1216,12 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => { export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const res = await apiClient.post('/v1/api/evidence/indicators', { const res = await apiClient.post('/v1/api/evidence/indicators', {
projectId: toIntegerId(payload.projectId), projectId: toIntegerId(payload.projectId),
activityId: toNullableIntegerId(payload.activityId),
name: payload.name, name: payload.name,
unitOfMeasure: Number(payload.unitOfMeasure) || 1, unitOfMeasure: Number(payload.unitOfMeasure) || 1,
baselineAmount: Number(payload.baselineAmount) || 0, baselineAmount: Number(payload.baselineAmount) || 0,
targetAmount: Number(payload.targetAmount) || 0, targetAmount: Number(payload.targetAmount) || 0,
targetCost: Number(payload.targetCost) || 0,
}); });
return res.data; return res.data;
}; };
@@ -742,21 +742,26 @@ export interface Indicator {
id: number | string; id: number | string;
tenantId?: number | string; tenantId?: number | string;
projectId: number | string; projectId: number | string;
activityId?: number | string | null;
name: string | null; name: string | null;
unitOfMeasure?: UnitOfMeasure | null; unitOfMeasure?: UnitOfMeasure | null;
baselineAmount: number; baselineAmount: number;
targetAmount: number; targetAmount: number;
actualAmount: number; actualAmount: number;
targetCost?: number;
actualCost?: number;
createdBy: string; createdBy: string;
createdAt: string; createdAt: string;
} }
export interface CreateIndicatorRequest { export interface CreateIndicatorRequest {
projectId: number | string; projectId: number | string;
activityId?: number | string | null;
name: string; name: string;
unitOfMeasure: UnitOfMeasure; unitOfMeasure: UnitOfMeasure;
baselineAmount: number; baselineAmount: number;
targetAmount: number; targetAmount: number;
targetCost?: number;
} }
export interface Risk { export interface Risk {
@@ -18,6 +18,8 @@ public class Activity : Models.Activity, ITenantEntity
public virtual Milestone? Milestone { get; set; } public virtual Milestone? Milestone { get; set; }
public virtual ICollection<Evidence> Evidences { get; set; } = []; public virtual ICollection<Evidence> Evidences { get; set; } = [];
public virtual ICollection<Indicator> Indicators { get; set; } = [];
} }
public sealed class ActivityConfiguration : IEntityTypeConfiguration<Activity> public sealed class ActivityConfiguration : IEntityTypeConfiguration<Activity>
@@ -9,6 +9,8 @@ public class Indicator : Models.Indicator, ITenantEntity
public virtual Project? Project { get; set; } public virtual Project? Project { get; set; }
public virtual Activity? Activity { get; set; }
public virtual User? Creator { get; set; } public virtual User? Creator { get; set; }
} }
@@ -21,6 +23,7 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
builder.HasKey(pk => pk.Id); builder.HasKey(pk => pk.Id);
builder.Property(f => f.TenantId).IsRequired(); builder.Property(f => f.TenantId).IsRequired();
builder.Property(f => f.ProjectId).IsRequired(); builder.Property(f => f.ProjectId).IsRequired();
builder.Property(f => f.ActivityId).IsRequired(false);
builder.Property(f => f.CreatedBy).IsRequired(); builder.Property(f => f.CreatedBy).IsRequired();
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()"); builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
builder.Property(f => f.Name).IsRequired().HasMaxLength(LabelLength); 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.BaselineAmount).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.TargetAmount).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.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) builder.HasOne(f => f.Tenant)
.WithMany() .WithMany()
@@ -41,10 +46,16 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
.IsRequired() .IsRequired()
.OnDelete(DeleteBehavior.Restrict); .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) builder.HasOne(f => f.Creator)
.WithMany() .WithMany()
.HasForeignKey(fk => fk.CreatedBy) .HasForeignKey(fk => fk.CreatedBy)
.IsRequired() .IsRequired()
.OnDelete(DeleteBehavior.NoAction); .OnDelete(DeleteBehavior.NoAction);
} }
} }
@@ -201,7 +201,10 @@ public static class Mappers
CreatedBy = entity.CreatedBy, CreatedBy = entity.CreatedBy,
Name = entity.Name, Name = entity.Name,
ProjectId = entity.ProjectId, ProjectId = entity.ProjectId,
ActivityId = entity.ActivityId,
TargetAmount = entity.TargetAmount, TargetAmount = entity.TargetAmount,
TargetCost = entity.TargetCost,
ActualCost = entity.ActualCost,
UnitOfMeasure = entity.UnitOfMeasure UnitOfMeasure = entity.UnitOfMeasure
}; };
@@ -8,6 +8,8 @@ public class Indicator
public long ProjectId { get; set; } public long ProjectId { get; set; }
public long? ActivityId { get; set; }
public Guid CreatedBy { get; set; } public Guid CreatedBy { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
@@ -21,4 +23,8 @@ public class Indicator
public decimal TargetAmount { get; set; } public decimal TargetAmount { get; set; }
public decimal ActualAmount { 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")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("ActivityId")
.HasColumnType("bigint");
b.Property<decimal>("ActualAmount") b.Property<decimal>("ActualAmount")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)"); .HasColumnType("numeric(18,2)");
b.Property<decimal>("ActualCost")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasDefaultValueSql("0");
b.Property<decimal>("BaselineAmount") b.Property<decimal>("BaselineAmount")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)"); .HasColumnType("numeric(18,2)");
@@ -662,6 +671,12 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)"); .HasColumnType("numeric(18,2)");
b.Property<decimal>("TargetCost")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasDefaultValueSql("0");
b.Property<long>("TenantId") b.Property<long>("TenantId")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -672,6 +687,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ActivityId");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
b.HasIndex("ProjectId"); b.HasIndex("ProjectId");
@@ -1929,6 +1946,11 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
modelBuilder.Entity("PostFundManagement.Domain.Entities.Indicator", b => 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") b.HasOne("PostFundManagement.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatedBy") .HasForeignKey("CreatedBy")
@@ -1947,6 +1969,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.OnDelete(DeleteBehavior.NoAction) .OnDelete(DeleteBehavior.NoAction)
.IsRequired(); .IsRequired();
b.Navigation("Activity");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("Project"); b.Navigation("Project");
@@ -2440,6 +2464,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b => modelBuilder.Entity("PostFundManagement.Domain.Entities.Activity", b =>
{ {
b.Navigation("Evidences"); b.Navigation("Evidences");
b.Navigation("Indicators");
}); });
modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b => modelBuilder.Entity("PostFundManagement.Domain.Entities.Award", b =>