diff --git a/PostFundManagement.Api/Endpoints/Grants/UpdateAwardEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/UpdateAwardEndpoint.cs new file mode 100644 index 0000000..8e557ab --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/UpdateAwardEndpoint.cs @@ -0,0 +1,36 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Grants; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Grants; + +[ApiVersionTarget(1)] +public sealed class UpdateAwardEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPut("api/grants/awards", async (UpdateAwardRequest request, HttpContext context, + IGrantsService service, CancellationToken cancellationToken = default) => + await service.UpdateAwardAsync(request, context.GetUserId(), cancellationToken) switch + { + { IsSuccess: true } => Results.Ok(), + var r when r.HasError() => Results.NotFound(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Updates a registered funding award") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(UpdateAwardEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Grants); + } +} diff --git a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs index 17ad4a3..86542bc 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Services.Grants; +using PostFundManagement.Application.Services.Grants; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; @@ -45,6 +45,10 @@ public interface IGrantsService [Audit] Task CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default); + [Log] + [Audit] + Task UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default); + [Log] [Audit] Task> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default); diff --git a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs index ef14bed..bb71e7d 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs @@ -12,6 +12,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache public async Task CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => await service.CreateAwardAsync(request, userId, cancellationToken); + public async Task UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => + await service.UpdateAwardAsync(request, userId, cancellationToken); + public async Task CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => await service.CreateBudgetAsync(request, userId, cancellationToken); diff --git a/PostFundManagement.Application/Services/Grants/GrantsService.cs b/PostFundManagement.Application/Services/Grants/GrantsService.cs index 59450b9..60065aa 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsService.cs @@ -206,6 +206,44 @@ public sealed class GrantsService(IDbContextFactory contex return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award")); })); + public async Task UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => + await Result.Try((Func>)(async () => + { + if (request.Amount <= 0) + return Result.Fail(ValidationError.Create("Approved amount must be greater than zero.")); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var existingAward = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken); + if (existingAward is null) + return Result.Fail(NotFoundError.Create("Award not found")); + + if (existingAward.Status == ApprovalStatus.Approved) + return Result.Fail(ValidationError.Create("Approved awards are immutable and cannot be updated.")); + + if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken)) + return Result.Fail(NotFoundError.Create("Organisation not found")); + + if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) + return Result.Fail(NotFoundError.Create("Programme not found")); + + existingAward.OrganisationId = request.OrganisationId; + existingAward.ProgrammeId = request.ProgrammeId; + existingAward.Amount = request.Amount; + if (request.Status.HasValue) + { + existingAward.Status = request.Status.Value; + if (request.Status.Value == ApprovalStatus.Approved) + { + existingAward.ApprovedBy = userId; + } + } + existingAward.UpdatedBy = userId; + existingAward.UpdatedAt = DateTime.UtcNow; + + return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update award")); + })); + public async Task> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Grants/Records.cs b/PostFundManagement.Application/Services/Grants/Records.cs index fbe18c5..f8e6609 100644 --- a/PostFundManagement.Application/Services/Grants/Records.cs +++ b/PostFundManagement.Application/Services/Grants/Records.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain; +using PostFundManagement.Domain; namespace PostFundManagement.Application.Services.Grants; @@ -8,6 +8,8 @@ public sealed record UpdateContractRequest([Required] long ContractId, [Required public sealed record CreateAwardRequest([Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount); +public sealed record UpdateAwardRequest([Required] long AwardId, [Required] long OrganisationId, [Required] long ProgrammeId, [Required] decimal Amount, ApprovalStatus? Status); + public sealed record CreateBudgetRequest([Required] long ProjectId, [Required][MaxLength(LabelLength)] string Name, [Required] decimal Amount); diff --git a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx index f91766b..ec7dab9 100644 --- a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx @@ -90,14 +90,15 @@ export const AccountActivationModal: React.FC = ({ ); } // If result.success is true, AuthProvider updates state, and this modal unmounts automatically! - } catch (err: any) { - if (err?.response?.status === 401) { + } catch (err: unknown) { + const errObj = err as { response?: { status?: number }; message?: string }; + if (errObj?.response?.status === 401) { await onLogout(); navigate('/', { replace: true }); return; } setErrorMessage( - err?.message || + errObj?.message || 'Account activation check encountered an error. Please confirm with your administrator and try again.' ); } finally { diff --git a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx index 0a9e728..67d686e 100644 --- a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx +++ b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx @@ -169,8 +169,8 @@ export const FundingInstrumentsSection: React.FC setEditingInstrument(null); if (onCloseCreateModalExternal) onCloseCreateModalExternal(); }, - onError: (err: any) => { - toastError('Save Failed', err?.message || 'Failed to save funding instrument'); + onError: (err: unknown) => { + toastError('Save Failed', err); }, }); diff --git a/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx index e716a81..5c9faf8 100644 --- a/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx +++ b/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx @@ -166,8 +166,8 @@ export const RulesSection: React.FC = ({ setEditingRule(null); if (onCloseCreateModalExternal) onCloseCreateModalExternal(); }, - onError: (err: any) => { - toastError('Save Failed', err?.message || 'Failed to save programme rule'); + onError: (err: unknown) => { + toastError('Save Failed', err); }, }); diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index c34d60a..ca79af0 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -527,9 +527,6 @@ export const AppLayout: React.FC = () => { {userRole} -

- {tokenUserEmail} -

- + {Number(contract.status) !== ContractStatus.Active && ( + + )}
@@ -845,18 +850,28 @@ export const ContractsManagement: React.FC = ({ {/* Actions */} - + {Number(contract.status) === ContractStatus.Active ? ( + + + Active (Locked) + + ) : ( + + )} ); @@ -1229,34 +1244,41 @@ export const ContractsManagement: React.FC = ({ - {(() => { - const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy); - const isSelf = isMaker(contractToUpdate?.createdBy); + {Number(contractToUpdate?.status) === ContractStatus.Active ? ( +
+ + Active / Executed (Immutable - Cannot Be Changed) +
+ ) : ( + (() => { + const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy); + const isSelf = isMaker(contractToUpdate?.createdBy); - return ( - - ); - })()} + return ( + + ); + })() + )}
@@ -1269,7 +1291,7 @@ export const ContractsManagement: React.FC = ({ - {canAdd('evidence_risks') && ( - - )} - - {canApproveRecord('evidence_risks', ev.createdBy) && ( - - )} - {canApprove('evidence_risks') && isMaker(ev.createdBy) && ( - - Maker-Checker (Creator) + + Approved (Immutable) + ) : ( + <> + {canAdd('evidence_risks') && ( + + )} + + {canApproveRecord('evidence_risks', ev.createdBy) && ( + + )} + {canApprove('evidence_risks') && isMaker(ev.createdBy) && ( + + Maker-Checker (Creator) + + )} + )}
-
mutation.mutate(data as any))} className="space-y-3"> + mutation.mutate(data))} className="space-y-3">
= ({ const { success, error: toastError } = useToast(); const form = useForm({ - resolver: zodResolver(projectSchema) as any, + resolver: zodResolver(projectSchema), defaultValues: { name: '', description: '', @@ -125,7 +125,7 @@ export const ProjectFormModal: React.FC = ({ helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance." maxWidth="lg" > - mutation.mutate(data as any))} className="space-y-4"> + mutation.mutate(data))} className="space-y-4">
diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectIndicatorsModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectIndicatorsModal.tsx index d71455f..90fe010 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectIndicatorsModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectIndicatorsModal.tsx @@ -63,7 +63,7 @@ export const ProjectIndicatorsModal: React.FC = ({ }); const form = useForm({ - resolver: zodResolver(indicatorSchema) as any, + resolver: zodResolver(indicatorSchema), defaultValues: { name: '', unitOfMeasure: UnitOfMeasure.Count, @@ -173,7 +173,7 @@ export const ProjectIndicatorsModal: React.FC = ({
- createMutation.mutate(data as any))} className="space-y-3"> + createMutation.mutate(data))} className="space-y-3">
= ({ }); const form = useForm({ - resolver: zodResolver(milestoneSchema) as any, + resolver: zodResolver(milestoneSchema), defaultValues: { name: '', dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), @@ -210,7 +211,7 @@ export const ProjectMilestonesModal: React.FC = ({
- mutation.mutate(data as any))} className="space-y-3"> + mutation.mutate(data))} className="space-y-3">
- mutation.mutate(data as any))} className="space-y-3"> + mutation.mutate(data))} className="space-y-3">
= ({
)} - {/* Real-time Governance Status Dropdown */} -
- + statusMutation.mutate({ + projectId: project.id, + status: Number(e.target.value) as ApprovalStatus, + }) } - return ( - - ); - })} - + className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md" + title="Project Governance Clearance Status" + > + {APPROVAL_STATUS_OPTIONS.map((opt) => { + const isApprovedOpt = opt.value === ApprovalStatus.Approved; + const isOptDisabled = isApprovedOpt && !canApproveThis; + let reasonText = ''; + if (isApprovedOpt && !canApprove('projects_milestones')) { + reasonText = ' (Requires Update)'; + } else if (isApprovedOpt && isSelf) { + reasonText = ' (Maker-Checker)'; + } + return ( + + ); + })} + + )}
{/* Action Buttons */} diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ErrorBoundary.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ErrorBoundary.tsx index 0a68b5e..9e7731e 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ErrorBoundary.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ErrorBoundary.tsx @@ -99,7 +99,7 @@ export class ErrorBoundary extends React.Component { } public handleReset = () => { - (this as any).setState({ hasError: false, error: null }); + this.setState({ hasError: false, error: null }); }; public render() { diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx index 661abb9..613418b 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx @@ -81,8 +81,8 @@ export function ExportButton({ filename, columns, data, - tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', + tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`, + userEmail: user?.email || (claims?.email as string) || '', }); }; @@ -95,8 +95,8 @@ export function ExportButton({ filename, columns, data, - tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', + tenantName: (claims?.tenant_name as string) || (claims?.tenant as string) || `Tenant #${tenantId || 1}`, + userEmail: user?.email || (claims?.email as string) || '', }); }; diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index 19fc106..31a5c45 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -165,7 +165,7 @@ export const CreateUserModal: React.FC = ({ queryClient.invalidateQueries({ queryKey: ['users-directory'] }); onClose(); }, - onError: (err: any) => { + onError: (err: unknown) => { toastError('User Creation Failed', err); }, }); diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index 3105daf..e770bdd 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -117,8 +117,8 @@ export const UserSecurityModal: React.FC = ({ return null; }, [userRoles, userRoleStr, systemRoles]); - const assignedRoleId = assignedRole?.id || (assignedRole as any)?.Id || ''; - const assignedRoleName = assignedRole?.name || (assignedRole as any)?.Name || ''; + const assignedRoleId = assignedRole?.id || assignedRole?.Id || ''; + const assignedRoleName = assignedRole?.name || assignedRole?.Name || ''; // 4. Fetch claims derived from the user's role by hitting the endpoint const { @@ -197,9 +197,9 @@ export const UserSecurityModal: React.FC = ({ throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.'); } const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( - (r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) + (r) => r && (String(r.id) === String(roleId) || String(r.Id) === String(roleId)) ); - const roleName = selectedRole?.name || (selectedRole as any)?.Name || ''; + const roleName = selectedRole?.name || selectedRole?.Name || ''; if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) { throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); } @@ -214,7 +214,7 @@ export const UserSecurityModal: React.FC = ({ queryClient.invalidateQueries({ queryKey: ['users'] }); setSelectedRoleToAdd(''); }, - onError: (err: any) => toastError('Role Assignment Failed', err), + onError: (err: unknown) => toastError('Role Assignment Failed', err), }); const removeRoleMutation = useMutation({ @@ -227,14 +227,14 @@ export const UserSecurityModal: React.FC = ({ refetchRoleClaims(); queryClient.invalidateQueries({ queryKey: ['users'] }); }, - onError: (err: any) => toastError('Role Removal Failed', err), + onError: (err: unknown) => toastError('Role Removal Failed', err), }); // Session & Lockout Mutations const logoutMutation = useMutation({ mutationFn: () => forceLogoutUser(userId), onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'), - onError: (err: any) => toastError('Logout Failed', err), + onError: (err: unknown) => toastError('Logout Failed', err), }); const lockoutMutation = useMutation({ @@ -246,7 +246,7 @@ export const UserSecurityModal: React.FC = ({ ); refetchUser(); }, - onError: (err: any) => toastError('Lockout Update Failed', err), + onError: (err: unknown) => toastError('Lockout Update Failed', err), }); const statusMutation = useMutation({ @@ -256,14 +256,14 @@ export const UserSecurityModal: React.FC = ({ refetchUser(); queryClient.invalidateQueries({ queryKey: ['users'] }); }, - onError: (err: any) => toastError('Status Update Failed', err), + onError: (err: unknown) => toastError('Status Update Failed', err), }); const availableRoles = React.useMemo(() => { return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { if (!r) return false; - const rId = r.id || (r as any).Id; - const rName = r.name || (r as any).Name || ''; + const rId = r.id || r.Id; + const rName = r.name || r.Name || ''; if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } @@ -446,7 +446,7 @@ export const UserSecurityModal: React.FC = ({ - {availableRoles.map((r: any) => { + {availableRoles.map((r) => { const roleOptId = r?.id || r?.Id || String(r); const roleOptName = r?.name || r?.Name || String(r); return ( @@ -485,8 +485,8 @@ export const UserSecurityModal: React.FC = ({ ) : ( (Array.isArray(userRoles) ? userRoles : []).map((role) => { if (!role) return null; - const roleId = role.id || (role as any).Id || String(role); - const roleName = role.name || (role as any).Name || String(role); + const roleId = role.id || role.Id || String(role); + const roleName = role.name || role.Name || String(role); return (
diff --git a/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx b/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx index f8a2aff..efece38 100644 --- a/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx @@ -20,6 +20,7 @@ import { Sparkles, MapPin, FileCheck, + Lock, } from 'lucide-react'; import { getProjects, @@ -194,7 +195,7 @@ export const BeneficiariesPage: React.FC = () => { benForm.reset(); success('Beneficiary Registered', 'New project beneficiary profile successfully created.'); }, - onError: (err: any) => toastError('Registration Failed', err), + onError: (err: unknown) => toastError('Registration Failed', err), }); const updateBenMutation = useMutation({ @@ -216,7 +217,7 @@ export const BeneficiariesPage: React.FC = () => { benForm.reset(); success('Beneficiary Updated', 'Beneficiary details and targets updated.'); }, - onError: (err: any) => toastError('Update Failed', err), + onError: (err: unknown) => toastError('Update Failed', err), }); const createCompMutation = useMutation({ @@ -231,7 +232,7 @@ export const BeneficiariesPage: React.FC = () => { compForm.reset(); success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.'); }, - onError: (err: any) => toastError('Creation Failed', err), + onError: (err: unknown) => toastError('Creation Failed', err), }); const updateCompMutation = useMutation({ @@ -251,7 +252,7 @@ export const BeneficiariesPage: React.FC = () => { compForm.reset(); success('Compliance Item Updated', 'Status and requirement record updated.'); }, - onError: (err: any) => toastError('Update Failed', err), + onError: (err: unknown) => toastError('Update Failed', err), }); const createOutcomeMutation = useMutation({ @@ -265,7 +266,7 @@ export const BeneficiariesPage: React.FC = () => { outcomeForm.reset(); success('Outcome Logged', 'Beneficiary outcome and impact metric registered.'); }, - onError: (err: any) => toastError('Failed to Record Outcome', err), + onError: (err: unknown) => toastError('Failed to Record Outcome', err), }); // Filtered & Sorted Beneficiaries @@ -1020,30 +1021,40 @@ export const BeneficiariesPage: React.FC = () => { {editingComp && (
- + {Number(editingComp.status) === ApprovalStatus.Approved ? ( +
+ + Approved (Immutable - Cannot Be Changed) +
+ ) : ( + + )}
)} diff --git a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx index 8fcd362..88389a2 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx @@ -20,6 +20,7 @@ import { User, Clock, ArrowRight, + Lock, } from 'lucide-react'; import { getProjects, @@ -238,7 +239,7 @@ export const ChangeManagementPage: React.FC = () => { issueForm.reset(); success('Issue Logged', 'New project issue created and assigned.'); }, - onError: (err: any) => toastError('Creation Failed', err), + onError: (err: unknown) => toastError('Creation Failed', err), }); const updateIssueMutation = useMutation({ @@ -273,7 +274,7 @@ export const ChangeManagementPage: React.FC = () => { editIssueForm.reset(); success('Issue Updated', 'Issue record and resolution status saved successfully.'); }, - onError: (err: any) => toastError('Update Failed', err), + onError: (err: unknown) => toastError('Update Failed', err), }); const resolveIssueMutation = useMutation({ @@ -297,7 +298,7 @@ export const ChangeManagementPage: React.FC = () => { resolveIssueForm.reset(); success('Issue Resolved', 'Resolution recorded and issue status updated.'); }, - onError: (err: any) => toastError('Resolution Failed', err), + onError: (err: unknown) => toastError('Resolution Failed', err), }); const createChangeMutation = useMutation({ @@ -317,7 +318,7 @@ export const ChangeManagementPage: React.FC = () => { changeForm.reset(); success('Change Request Submitted', 'Formal change request submitted for review.'); }, - onError: (err: any) => toastError('Submission Failed', err), + onError: (err: unknown) => toastError('Submission Failed', err), }); const resolveChangeMutation = useMutation({ @@ -338,12 +339,15 @@ export const ChangeManagementPage: React.FC = () => { resolveChangeForm.reset(); success('Change Decision Applied', 'Change request decision successfully resolved.'); }, - onError: (err: any) => toastError('Decision Failed', err), + onError: (err: unknown) => toastError('Decision Failed', err), }); const updateChangeMutation = useMutation({ mutationFn: (vals: EditChangeFormValues) => { if (!editingChange) throw new Error('No change request selected'); + if (Number(editingChange.status) === ApprovalStatus.Approved) { + throw new Error('Approved change requests are immutable and cannot be updated.'); + } return updateChangeRequest({ changeRequestId: editingChange.id, title: vals.title, @@ -359,7 +363,7 @@ export const ChangeManagementPage: React.FC = () => { editChangeForm.reset(); success('Change Request Updated', 'Change proposal and governance status saved.'); }, - onError: (err: any) => toastError('Update Failed', err), + onError: (err: unknown) => toastError('Update Failed', err), }); // Filtered & Sorted Issues @@ -481,6 +485,10 @@ export const ChangeManagementPage: React.FC = () => { }; const openEditChange = async (cr: ChangeRequest) => { + if (Number(cr.status) === ApprovalStatus.Approved) { + toastError('Record Immutable', 'Approved and enacted change requests are immutable.'); + return; + } try { // 1. Uses GET method (NOT POST) // 2. Passes ChangeRequestId (NOT ProjectId) @@ -1009,19 +1017,20 @@ export const ChangeManagementPage: React.FC = () => {
- - {cr.status === ApprovalStatus.Approved ? ( - - Resolved & Enacted + {Number(cr.status) === ApprovalStatus.Approved ? ( + + + Resolved & Enacted (Immutable) ) : ( <> + {canApproveRecord('change_management', cr.createdBy) && ( - )} )}
@@ -1521,29 +1522,39 @@ export const ChangeManagementPage: React.FC = () => {
- + {editingChange && Number(editingChange.status) === ApprovalStatus.Approved ? ( +
+ + Approved & Enacted (Immutable - Cannot Be Changed) +
+ ) : ( + + )}
diff --git a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx index 56d92c4..7a2a121 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx @@ -33,6 +33,17 @@ import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { useTableSort, SortableTh } from '../utils/tableSort'; +interface AttentionProject { + id: number | string; + name: string; + programme: string; + status: string; + risk: string; + financialVariance: string; + varianceType: string; + maxScore: number; +} + export const ExecutiveDashboard: React.FC = () => { const queryClient = useQueryClient(); const [selectedProgramme, setSelectedProgramme] = useState('All'); @@ -335,12 +346,12 @@ export const ExecutiveDashboard: React.FC = () => { sortColumn: projectSortCol, sortDirection: projectSortDir, handleSort: handleProjectSort, - } = useTableSort(attentionProjects, null, null, { - name: (p: any) => p.name, - programme: (p: any) => p.programme, - status: (p: any) => p.status, - risk: (p: any) => p.risk, - financialVariance: (p: any) => p.financialVariance, + } = useTableSort(attentionProjects, null, null, { + name: (p) => p.name, + programme: (p) => p.programme, + status: (p) => p.status, + risk: (p) => p.risk, + financialVariance: (p) => p.financialVariance, }); return ( diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 659996b..4c9b92c 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -26,6 +26,7 @@ import { ChevronUp, CheckCircle2, Loader2, + Lock, } from 'lucide-react'; import { getOrganisations, @@ -339,8 +340,9 @@ export const GovernancePage: React.FC = () => { setQuickUserPhone(''); setQuickUserIdNumber(''); setIsQuickAddUserOpen(false); - } catch (err: any) { - const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.'; + } catch (err: unknown) { + const errObj = err as { response?: { data?: { detail?: string } }; message?: string }; + const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.'; setQuickUserError(errorDetail); toastError('Failed to create user', err); } finally { @@ -538,7 +540,7 @@ export const GovernancePage: React.FC = () => { // Forms const orgForm = useForm({ - resolver: zodResolver(organisationSchema) as any, + resolver: zodResolver(organisationSchema), defaultValues: { name: '', registrationNo: '', @@ -550,7 +552,7 @@ export const GovernancePage: React.FC = () => { }); const portfolioForm = useForm({ - resolver: zodResolver(portfolioSchema) as any, + resolver: zodResolver(portfolioSchema), defaultValues: { name: '', description: '', @@ -560,7 +562,7 @@ export const GovernancePage: React.FC = () => { }); const programmeForm = useForm({ - resolver: zodResolver(programmeSchema) as any, + resolver: zodResolver(programmeSchema), defaultValues: { name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, @@ -685,9 +687,9 @@ export const GovernancePage: React.FC = () => { const targetUserId = org.userId || - (org as any).UserId || - (org as any).organisationUser || - (org as any).OrganisationUser || + org.UserId || + org.organisationUser || + org.OrganisationUser || org.createdBy || ''; @@ -1988,33 +1990,43 @@ export const GovernancePage: React.FC = () => { placement="top" />
- {(() => { - const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy); - const isSelf = isMaker(selectedProgrammeForDetail?.createdBy); + {selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? ( +
+ + Approved (Immutable - Cannot Be Changed) +
+ ) : ( + (() => { + const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy); + const isSelf = isMaker(selectedProgrammeForDetail?.createdBy); - return ( - - ); - })()} + return ( + + ); + })() + )}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx index 7179395..9f5e5dd 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx @@ -25,6 +25,7 @@ import { getAwards, getAward, createAward, + updateAward, getBudgets, getBudget, createBudget, @@ -52,6 +53,7 @@ import { Disbursement, ApprovalStatus, CreateAwardRequest, + UpdateAwardRequest, CreateBudgetRequest, UpdateBudgetRequest, CreateDisbursementRequest, @@ -77,6 +79,15 @@ const awardSchema = z.object({ programmeId: z.coerce.number().min(1, 'Select funding programme'), }); +const editAwardSchema = z.object({ + organisationId: z.coerce.number().min(1, 'Recipient organisation is required'), + programmeId: z.coerce.number().min(1, 'Funding programme is required'), + amount: z + .coerce + .number() + .positive('Approved funding amount must be strictly greater than zero (no negative or zero values)'), +}); + const budgetSchema = z.object({ projectId: z.coerce.number().min(1, 'Select project'), name: z.string().min(3, 'Budget category name required'), @@ -93,6 +104,7 @@ const disbursementSchema = z.object({ }); type AwardFormValues = z.infer; +type EditAwardFormValues = z.infer; type BudgetFormValues = z.infer; type DisbursementFormValues = z.infer; @@ -105,6 +117,9 @@ export const GrantsPage: React.FC = () => { // Modals const [isAwardModalOpen, setIsAwardModalOpen] = useState(false); + const [editingAward, setEditingAward] = useState(null); + const [isEditAwardModalOpen, setIsEditAwardModalOpen] = useState(false); + const [selectedAwardStatus, setSelectedAwardStatus] = useState(ApprovalStatus.Pending); const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false); const [editingBudget, setEditingBudget] = useState(null); const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false); @@ -165,7 +180,7 @@ export const GrantsPage: React.FC = () => { // Forms const awardForm = useForm({ - resolver: zodResolver(awardSchema) as any, + resolver: zodResolver(awardSchema), defaultValues: { referenceNo: '', projectId: projects[0]?.id ? Number(projects[0].id) : 401, @@ -176,8 +191,12 @@ export const GrantsPage: React.FC = () => { }, }); + const editAwardForm = useForm({ + resolver: zodResolver(editAwardSchema), + }); + const budgetForm = useForm({ - resolver: zodResolver(budgetSchema) as any, + resolver: zodResolver(budgetSchema), defaultValues: { projectId: projects[0]?.id ? Number(projects[0].id) : 401, name: '', @@ -186,7 +205,7 @@ export const GrantsPage: React.FC = () => { }); const disbursementForm = useForm({ - resolver: zodResolver(disbursementSchema) as any, + resolver: zodResolver(disbursementSchema), defaultValues: { projectId: projects[0]?.id ? Number(projects[0].id) : 401, amount: 2500000, @@ -211,6 +230,21 @@ export const GrantsPage: React.FC = () => { }, }); + const updateAwardMutation = useMutation({ + mutationFn: (data: UpdateAwardRequest) => updateAward(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['awards'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + success('Award Updated', 'Award details saved successfully.'); + setIsEditAwardModalOpen(false); + setEditingAward(null); + editAwardForm.reset(); + }, + onError: (err: unknown) => { + toastError('Award update failed', err); + }, + }); + const budgetMutation = useMutation({ mutationFn: (data: CreateBudgetRequest) => createBudget(data), onSuccess: () => { @@ -837,18 +871,21 @@ export const GrantsPage: React.FC = () => { Date Registered + + Actions + {loadingAwards ? ( - + Loading registered awards... ) : sortedAwards.length === 0 ? ( - + {searchTerm ? 'No awards match your search query.' : 'No registered awards found.'} @@ -878,6 +915,36 @@ export const GrantsPage: React.FC = () => { {formatDate(awd.createdAt)} + + {Number(awd.status) === ApprovalStatus.Approved ? ( + + + Approved (Immutable) + + ) : ( + + )} + ); }) @@ -989,40 +1056,52 @@ export const GrantsPage: React.FC = () => { {getUserEmail(b.createdBy)}
- - {b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && ( - - )} - {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && ( - - Maker-Checker + + Approved (Immutable) + ) : ( + <> + + {canApproveRecord('grant_financials', b.createdBy) && ( + + )} + {canApprove('grant_financials') && isMaker(b.createdBy) && ( + + Maker-Checker + + )} + )}
@@ -1455,6 +1534,140 @@ export const GrantsPage: React.FC = () => { + {/* Modal: Edit Award */} + { + setIsEditAwardModalOpen(false); + setEditingAward(null); + }} + title={`Edit Award: ${editingAward?.referenceNo || `#${editingAward?.id}`}`} + subtitle="Approved funding award record" + helpText="Modify the funding programme, recipient organisation, and approved award allocation." + > +
{ + if (!editingAward) return; + if (Number(editingAward.status) === ApprovalStatus.Approved) { + toastError('Record Immutable', 'Approved awards cannot be modified.'); + return; + } + updateAwardMutation.mutate({ + awardId: editingAward.id, + organisationId: values.organisationId, + programmeId: values.programmeId, + amount: values.amount, + status: selectedAwardStatus, + }); + })} + className="space-y-4" + > +
+ + + {editAwardForm.formState.errors.programmeId && ( +

+ {editAwardForm.formState.errors.programmeId.message} +

+ )} +
+ +
+ + + {editAwardForm.formState.errors.organisationId && ( +

+ {editAwardForm.formState.errors.organisationId.message} +

+ )} +
+ +
+ + + {editAwardForm.formState.errors.amount && ( +

+ {editAwardForm.formState.errors.amount.message} +

+ )} +
+ +
+ + {Number(editingAward?.status) === ApprovalStatus.Approved ? ( +
+ + Approved (Immutable - Cannot Be Changed) +
+ ) : ( + + )} +
+ +
+ + +
+
+
+ {/* Modal: Add Budget */} { subtitle="Maintain approved expenditure breakdown and commitments" helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings." > -
budgetMutation.mutate(data as any))} className="space-y-4"> + budgetMutation.mutate(data as CreateBudgetRequest))} className="space-y-4">
@@ -1567,29 +1780,36 @@ export const GrantsPage: React.FC = () => { - + {Number(editingBudget?.status) === ApprovalStatus.Approved ? ( +
+ + Approved (Immutable - Status Cannot Be Changed) +
+ ) : ( + + )}
@@ -1605,7 +1825,7 @@ export const GrantsPage: React.FC = () => {
- {activeTab === 'indicators' && canAdd('evidence_risks') && ( - - )} + {activeTab === 'risks' && canAdd('evidence_risks') && ( - + - )} -
- - {/* Action Buttons: Refresh & Export */} -
- - - -
-
- - {/* Indicators Table Card */} -
-
-

- - Performance Indicators & Actuals Ledger -

- - Showing {filteredIndicators.length} of {indicators.length} indicators - -
-
- - - - - Indicator Name - - - Project ID - - - Unit of Measure - - - Baseline - - - Target - - - Actual Recorded - - - Status - - - - - - {loadingIndicators ? ( - - - - ) : sortedIndicators.length === 0 ? ( - - - - ) : ( - sortedIndicators.map((ind) => { - const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount); - return ( - - - - - - - - - - - ); - }) - )} - -
Action
- - Loading project indicators from API... -
- - {indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all' - ? 'No indicators match your filter criteria.' - : 'No performance indicators defined yet. Click "Define Indicator" to begin.'} -
{ind.name}Project #{ind.projectId}{getUnitOfMeasureLabel(ind.unitOfMeasure)}{ind.baselineAmount}{ind.targetAmount} - {ind.actualAmount} - - - {statusObj.label} - - - -
-
-
-
- )} - {/* Tab 3: Risk Register & Heatmap (M09) */} {activeTab === 'risks' && (
@@ -970,7 +735,7 @@ export const MonitoringPage: React.FC = () => { subtitle="Logical Framework & Theory of Change metric" helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy." > - indicatorMutation.mutate(data as any))} className="space-y-4"> + indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
@@ -1157,7 +922,7 @@ export const MonitoringPage: React.FC = () => { subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'} helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance." > - riskMutation.mutate(data as any))} className="space-y-4"> + riskMutation.mutate(data))} className="space-y-4"> {!editingRisk && (
diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index fffbe61..886772f 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -1,6 +1,9 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { FolderKanban, Plus, @@ -22,6 +25,10 @@ import { CheckCircle2, SlidersHorizontal, Eye, + RefreshCw, + Filter, + FileText, + Activity, } from 'lucide-react'; import { getProjects, @@ -30,9 +37,11 @@ import { getAllMilestones, getAllRisks, getAllIndicators, + createIndicator, + recordIndicatorActual, getBudgets, - updateProjectStatus, } from '../services/api'; +import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; @@ -43,6 +52,9 @@ import { Project, Programme, Milestone, + Indicator, + CreateIndicatorRequest, + UnitOfMeasure, ApprovalStatus, IndustrySector, ProjectType, @@ -51,6 +63,8 @@ import { getIndustrySectorLabel, getProjectTypeLabel, getApprovalStatusBadge, + getUnitOfMeasureLabel, + UNIT_OF_MEASURE_OPTIONS, formatDate, formatZAR, INDUSTRY_SECTOR_OPTIONS, @@ -65,6 +79,16 @@ import { ProjectActivitiesModal } from '../components/projects/ProjectActivities import { ProjectIndicatorsModal } from '../components/projects/ProjectIndicatorsModal'; import { ProjectRisksModal } from '../components/projects/ProjectRisksModal'; + +const indicatorSchema = z.object({ + projectId: z.coerce.number().min(1, 'Select project'), + name: z.string().min(5, 'Indicator statement / definition is required'), + unitOfMeasure: z.coerce.number().min(1, 'Unit of measure is required'), + baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'), + targetAmount: z.coerce.number().min(1, 'Target benchmark must be > 0'), +}); +type IndicatorFormValues = z.infer; + export interface ProjectScheduleRisk { isAtRisk: boolean; isOverdue: boolean; @@ -256,19 +280,6 @@ export const ProjectsPage: React.FC = () => { return map; }, [allBudgets]); - // Status Mutation - const statusMutation = useMutation({ - mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) => - updateProjectStatus({ projectId, status }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['projects'] }); - success('Status Updated', 'Project governance status changed successfully'); - }, - onError: (err: unknown) => { - toastError('Failed to update status', err); - }, - }); - // Filtered Projects const filteredProjects = useMemo(() => { return projects.filter((p) => { @@ -433,6 +444,194 @@ export const ProjectsPage: React.FC = () => { }).length; }, [projects, milestonesByProject]); + + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const [activeTab, setActiveTab] = useState<'projects' | 'indicators' | 'actuals'>( + tabParam === 'indicators' || tabParam === 'actuals' ? tabParam : 'projects' + ); + + useEffect(() => { + const t = searchParams.get('tab'); + if (t === 'projects' || t === 'indicators' || t === 'actuals') { + setActiveTab(t); + } + }, [searchParams]); + + const handleTabChange = (tab: 'projects' | 'indicators' | 'actuals') => { + setActiveTab(tab); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('tab', tab); + return next; + }); + }; + + // Indicators & Actuals Modals & States + const [isCreateIndicatorModalOpen, setIsCreateIndicatorModalOpen] = useState(false); + const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState(null); + const [actualInput, setActualInput] = useState(0); + + // Indicators Tab Filter States + const [indicatorSearchTerm, setIndicatorSearchTerm] = useState(''); + const [indicatorProjectFilter, setIndicatorProjectFilter] = useState('all'); + const [indicatorUnitFilter, setIndicatorUnitFilter] = useState('all'); + + // Actuals Tab Filter States + const [actualSearchTerm, setActualSearchTerm] = useState(''); + const [actualProjectFilter, setActualProjectFilter] = useState('all'); + const [actualStatusFilter, setActualStatusFilter] = useState('all'); + + // Indicator Status Thresholds (BR-008 & BR-009: >=90% On Track, 70-89% At Risk, <70% Off Track) + const getIndicatorStatus = (actual: number, target: number) => { + if (!target) return { label: 'Pending Target', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' }; + const pct = (actual / target) * 100; + if (pct >= 90) return { label: '≥90% (On Track)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }; + if (pct >= 70) return { label: '70–89% (At Risk)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }; + return { label: '<70% (Underperforming)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }; + }; + + const indicatorForm = useForm({ + resolver: zodResolver(indicatorSchema), + defaultValues: { + projectId: projects[0]?.id ? Number(projects[0].id) : 401, + name: '', + unitOfMeasure: UnitOfMeasure.Count, + baselineAmount: 0, + targetAmount: 100, + }, + }); + + const indicatorMutation = useMutation({ + mutationFn: (data: CreateIndicatorRequest) => createIndicator(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['indicators-all'] }); + queryClient.invalidateQueries({ queryKey: ['indicators'] }); + success('Indicator Defined', 'Performance indicator added to project monitoring plan (M08)'); + setIsCreateIndicatorModalOpen(false); + indicatorForm.reset(); + }, + onError: (err: unknown) => { + toastError('Failed to create indicator', err); + }, + }); + + const actualMutation = useMutation({ + mutationFn: ({ id, amount }: { id: string | number; amount: number }) => + recordIndicatorActual(id, amount), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['indicators-all'] }); + queryClient.invalidateQueries({ queryKey: ['indicators'] }); + success('Actual Value Recorded', 'M&E variance and indicator status recalculated (BR-009)'); + setSelectedIndicatorForActual(null); + }, + onError: (err: unknown) => { + toastError('Failed to record actual', err); + }, + }); + + // Filtered Indicators + const filteredIndicatorsList = useMemo(() => { + return allIndicators.filter((ind) => { + if (indicatorProjectFilter !== 'all' && String(ind.projectId) !== String(indicatorProjectFilter)) { + return false; + } + if (indicatorUnitFilter !== 'all' && String(ind.unitOfMeasure) !== String(indicatorUnitFilter)) { + return false; + } + if (!indicatorSearchTerm.trim()) return true; + const q = indicatorSearchTerm.toLowerCase(); + const name = (ind.name || '').toLowerCase(); + const projId = String(ind.projectId || '').toLowerCase(); + const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase(); + return name.includes(q) || projId.includes(q) || unit.includes(q); + }); + }, [allIndicators, indicatorProjectFilter, indicatorUnitFilter, indicatorSearchTerm]); + + // Filtered Actuals + const filteredActualsList = useMemo(() => { + return allIndicators.filter((ind) => { + if (actualProjectFilter !== 'all' && String(ind.projectId) !== String(actualProjectFilter)) { + return false; + } + if (actualStatusFilter !== 'all') { + const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount); + if (actualStatusFilter === 'green' && !statusObj.label.startsWith('≥90%')) return false; + if (actualStatusFilter === 'amber' && !statusObj.label.startsWith('70–89%')) return false; + if (actualStatusFilter === 'red' && !statusObj.label.startsWith('<70%')) return false; + if (actualStatusFilter === 'pending' && statusObj.label !== 'Pending Target') return false; + } + if (!actualSearchTerm.trim()) return true; + const q = actualSearchTerm.toLowerCase(); + const name = (ind.name || '').toLowerCase(); + const projId = String(ind.projectId || '').toLowerCase(); + const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase(); + return name.includes(q) || projId.includes(q) || unit.includes(q); + }); + }, [allIndicators, actualProjectFilter, actualStatusFilter, actualSearchTerm]); + + // Exports + const indicatorExportColumns: ExportColumn[] = [ + { header: 'Indicator ID', key: 'id' }, + { header: 'Indicator Statement', key: 'name' }, + { header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` }, + { header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) }, + { header: 'Baseline Value', key: 'baselineAmount' }, + { header: 'Target Value', key: 'targetAmount' }, + ]; + + const actualExportColumns: ExportColumn[] = [ + { header: 'Indicator Statement', key: 'name' }, + { header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` }, + { header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) }, + { header: 'Baseline', key: 'baselineAmount' }, + { header: 'Target Benchmark', key: 'targetAmount' }, + { header: 'Actual Recorded', key: 'actualAmount' }, + { + header: 'Variance', + key: 'actualAmount', + format: (val, item) => `${Number(item.targetAmount) - Number(val)}`, + }, + { + header: 'Achievement %', + key: 'actualAmount', + format: (val, item) => (item.targetAmount ? `${Math.round((Number(val) / Number(item.targetAmount)) * 100)}%` : '—'), + }, + { + header: 'Status', + key: 'actualAmount', + format: (val, item) => getIndicatorStatus(Number(val), Number(item.targetAmount)).label, + }, + ]; + + // Table Sort Hooks + const { + sortedData: sortedIndicatorsData, + sortColumn: indSortCol, + sortDirection: indSortDir, + handleSort: handleIndSort, + } = useTableSort(filteredIndicatorsList, null, null, { + name: (ind) => ind.name, + projectId: (ind) => ind.projectId, + unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure), + baselineAmount: (ind) => ind.baselineAmount, + targetAmount: (ind) => ind.targetAmount, + }); + + const { + sortedData: sortedActualsData, + sortColumn: actSortCol, + sortDirection: actSortDir, + handleSort: handleActSort, + } = useTableSort(filteredActualsList, null, null, { + name: (ind) => ind.name, + projectId: (ind) => ind.projectId, + unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure), + baselineAmount: (ind) => ind.baselineAmount, + targetAmount: (ind) => ind.targetAmount, + actualAmount: (ind) => ind.actualAmount, + status: (ind) => getIndicatorStatus(ind.actualAmount, ind.targetAmount).label, + }); return (
{/* Header Banner */} @@ -466,7 +665,7 @@ export const ProjectsPage: React.FC = () => {
- {canAdd('projects_milestones') && ( + {activeTab === 'projects' && canAdd('projects_milestones') && ( )} + {activeTab === 'indicators' && canAdd('projects_milestones') && ( + + )}
+ {/* 3 Navigation Tabs: Projects, Indicators, Actuals */} +
+ + + + + +
+ + {activeTab === 'projects' && ( +
{/* KPI Overview Summary Cards */}
@@ -701,19 +953,19 @@ export const ProjectsPage: React.FC = () => { - + Project Name & ID - + Parent Programme - + Schedule (Timeline) - + WBS Progress - +
Schedule Risk { />
- - Status - -
+ {loadingProjects ? ( - ) : sortedProjects.length === 0 ? ( - - {/* Parent Programme */} - + + {/* Schedule (Dates) */} + + + {/* Milestone Progress */} + - - {/* Schedule (Dates) */} - + + - {/* Milestone Progress */} - - - {/* Schedule Risk Column with Icon & Tooltip */} - - {/* Governance Status */} - - - {/* Row Level Buttons: View Project, Milestones, Activities, Indicators, Risks, Edit */} - ); @@ -1035,7 +1186,476 @@ export const ProjectsPage: React.FC = () => { - {/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */} + + + )} + + {/* TAB 2: Performance Indicators (M08) */} + {activeTab === 'indicators' && ( +
+ {/* Indicators KPI Summary Cards */} +
+
+
+ Total Indicators + +
+
+ {allIndicators.length} + Defined metrics +
+

Across all active projects

+
+ +
+
+ Targets Set + +
+
+ {allIndicators.filter((i) => Number(i.targetAmount) > 0).length} + Contracted milestones +
+

M&E logframe benchmarks

+
+ +
+
+ Baselines Recorded + +
+
+ {allIndicators.filter((i) => Number(i.baselineAmount) >= 0).length} + Pre-grant baselines +
+

Groundwork observations

+
+ +
+
+ Measurement Units + +
+
+ {new Set(allIndicators.map((i) => i.unitOfMeasure)).size} + Active dimensions +
+

Standardized measurement scales

+
+
+ + {/* Indicators Toolbar */} +
+
+
+ + setIndicatorSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" + /> +
+ +
+ + +
+ +
+ + +
+ + {(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all') && ( + + )} +
+ +
+ + + +
+
+ + {/* Indicators Table Card */} +
+
+

+ + Performance Indicators & Target Benchmarks +

+ + Showing {sortedIndicatorsData.length} of {allIndicators.length} indicators + +
+
+
ActionsActions
+
Loading project directory... @@ -742,7 +991,7 @@ export const ProjectsPage: React.FC = () => {
+

No matching projects found

@@ -778,7 +1027,7 @@ export const ProjectsPage: React.FC = () => { className="transition-colors cursor-pointer hover:bg-slate-900/60 text-slate-300 group" > {/* Project Name & Meta */} -
+
{ #{p.id} {getIndustrySectorLabel(p.sector)} • {getProjectTypeLabel(p.projectType)} @@ -809,69 +1058,69 @@ export const ProjectsPage: React.FC = () => {
-
- - {prog?.name || `Prog #${p.programmeId}`} + {/* Parent Programme */} +
+
+ + {prog?.name || `Prog #${p.programmeId}`} + + + Programme #{p.programmeId} + +
+
+
+
+ + {formatDate(p.startedAt)} → {formatDate(p.endedAt)} +
+ {p.startedAt && p.endedAt && ( + + {Math.max( + 1, + Math.round( + (new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime()) / + (1000 * 60 * 60 * 24 * 30) + ) + )}{' '} + Months Estimated Duration - - Programme #{p.programmeId} + )} +
+
+
+
+ = 100 ? 'text-emerald-400' : 'text-blue-400'}> + {progressPct}% + + + ({milestoneStats.verified}/{milestoneStats.total})
-
-
-
- - {formatDate(p.startedAt)} → {formatDate(p.endedAt)} -
- {p.startedAt && p.endedAt && ( - - {Math.max( - 1, - Math.round( - (new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime()) / - (1000 * 60 * 60 * 24 * 30) - ) - )}{' '} - Months Estimated Duration - - )} +
+
= 100 + ? 'bg-emerald-500' + : 'bg-gradient-to-r from-blue-500 to-indigo-500' + }`} + style={{ width: `${progressPct}%` }} + />
-
-
-
- = 100 ? 'text-emerald-400' : 'text-blue-400'}> - {progressPct}% - - - ({milestoneStats.verified}/{milestoneStats.total}) - -
-
-
= 100 - ? 'bg-emerald-500' - : 'bg-gradient-to-r from-blue-500 to-indigo-500' - }`} - style={{ width: `${progressPct}%` }} - /> -
-
-
e.stopPropagation()}> + {/* Schedule Risk Column with Icon & Tooltip */} + e.stopPropagation()}>
{scheduleRisk.isOverdue ? ( {
e.stopPropagation()}> -
- {(() => { - const canApproveThis = canApproveRecord('projects_milestones', p.createdBy); - const isSelf = isMaker(p.createdBy); - const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones'); - - return ( - - ); - })()} -
-
e.stopPropagation()}> -
- {/* Primary View Project Modal Button */} - - - {/* Milestones Modal Button */} - - - {/* Activities Modal Button */} - - - {/* Indicators Modal Button */} - - - {/* Risks Modal Button */} - - - {/* Edit Project Button */} - -
+ {/* Row Action: View Project (Opens Large WBS & Gantt Console) */} +
e.stopPropagation()}> +
+ + + + Indicator Statement + + + Project + + + Unit of Measure + + + Baseline + + + Target + + + + + + {sortedIndicatorsData.length === 0 ? ( + + + + ) : ( + sortedIndicatorsData.map((ind) => ( + + + + + + + + + )) + )} + +
Action
+ + {indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all' + ? 'No indicators match your filter criteria.' + : 'No performance indicators defined yet. Click "Define Indicator" above to begin.'} +
{ind.name}Project #{ind.projectId}{getUnitOfMeasureLabel(ind.unitOfMeasure)}{ind.baselineAmount}{ind.targetAmount} + +
+
+
+
+ )} + + {/* TAB 3: Realized Actuals & Variance Ledger (M08 / BR-009) */} + {activeTab === 'actuals' && ( +
+ {/* Actuals RAG Summary Cards */} +
+
+
+ On Track (≥90%) + +
+
+ + {allIndicators.filter((i) => i.targetAmount > 0 && (i.actualAmount / i.targetAmount) >= 0.9).length} + + Satisfactory progress +
+

≥90% of target benchmark achieved

+
+ +
+
+ At Risk (70–89%) + +
+
+ + {allIndicators.filter((i) => { + if (!i.targetAmount) return false; + const r = i.actualAmount / i.targetAmount; + return r >= 0.7 && r < 0.9; + }).length} + + Requires monitoring +
+

Moderately behind milestones

+
+ +
+
+ Underperforming (<70%) + +
+
+ + {allIndicators.filter((i) => i.targetAmount > 0 && i.actualAmount > 0 && (i.actualAmount / i.targetAmount) < 0.7).length} + + Corrective action +
+

<70% of target achieved

+
+ +
+
+ Pending Actuals + +
+
+ + {allIndicators.filter((i) => !i.actualAmount || Number(i.actualAmount) === 0).length} + + Unrecorded +
+

Awaiting field monitoring returns

+
+
+ + {/* Actuals Toolbar */} +
+
+
+ + setActualSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" + /> +
+ +
+ + +
+ +
+ + +
+ + {(actualSearchTerm || actualProjectFilter !== 'all' || actualStatusFilter !== 'all') && ( + + )} +
+ +
+ + + +
+
+ + {/* Actuals Table Card */} +
+
+

+ + Realized Actuals & Variance Ledger +

+ + Showing {sortedActualsData.length} of {allIndicators.length} returns + +
+
+ + + + + Indicator Statement + + + Project + + + Unit + + + Baseline + + + Target + + + Actual + + + + RAG Status + + + + + + {sortedActualsData.length === 0 ? ( + + + + ) : ( + sortedActualsData.map((ind) => { + const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount); + const achievementPct = ind.targetAmount ? Math.round((Number(ind.actualAmount) / Number(ind.targetAmount)) * 100) : 0; + return ( + + + + + + + + + + + + ); + }) + )} + +
Achievement %Action
+ + {actualSearchTerm || actualProjectFilter !== 'all' || actualStatusFilter !== 'all' + ? 'No indicator returns match your filter criteria.' + : 'No indicator returns recorded yet. Click "Record Actual" to log field returns.'} +
{ind.name}Project #{ind.projectId}{getUnitOfMeasureLabel(ind.unitOfMeasure)}{ind.baselineAmount}{ind.targetAmount} + {ind.actualAmount} + + = 90 ? 'text-emerald-400 font-semibold' : achievementPct >= 70 ? 'text-amber-400 font-semibold' : 'text-rose-400 font-semibold'}> + {ind.targetAmount ? `${achievementPct}%` : '—'} + + + + {statusObj.label} + + + +
+
+
+
+ )} +{/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */} { @@ -1127,6 +1747,191 @@ export const ProjectsPage: React.FC = () => { }} project={activeModalProject} /> + + {/* Modal: Define M&E Performance Indicator */} + setIsCreateIndicatorModalOpen(false)} + title="Define M&E Performance Indicator" + subtitle="Establish quantitative output, outcome, or impact metric for project logframe (M08)" + helpText="Every performance indicator strictly enforces: Baseline -> Target -> Actual -> Variance -> Evidence (BR-009)." + > + indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4"> +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ +
+ + +
+ +
+ + {/* Modal: Record Actual Amount */} + setSelectedIndicatorForActual(null)} + title="Record Indicator Actual Value" + subtitle={selectedIndicatorForActual?.name || ''} + helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating (BR-009)." + > +
+
+ Baseline: {selectedIndicatorForActual?.baselineAmount} + Target Benchmark: {selectedIndicatorForActual?.targetAmount} +
+ +
+
+ + +
+ setActualInput(Number(e.target.value))} + className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition" + /> +
+ +
+ + +
+
+
+
); }; diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 83336e8..499fa88 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -192,7 +192,7 @@ export const SettingsPage: React.FC = () => { success('User Sync Complete', 'Authenticated user enabled and directory synced.'); queryClient.invalidateQueries({ queryKey: ['users'] }); }, - onError: (err: any) => { + onError: (err: unknown) => { toastError('Sync Failed', err); }, }); @@ -203,7 +203,7 @@ export const SettingsPage: React.FC = () => { success('Backend Integration Synced', 'System records and telemetry successfully synchronized.'); queryClient.invalidateQueries({ queryKey: ['users'] }); }, - onError: (err: any) => { + onError: (err: unknown) => { toastError('Integration Sync Failed', err); }, }); @@ -218,7 +218,7 @@ export const SettingsPage: React.FC = () => { setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status }); } }, - onError: (err: any) => { + onError: (err: unknown) => { toastError('Update Failed', err); }, }); @@ -287,7 +287,7 @@ export const SettingsPage: React.FC = () => { setInviteRecipientUuid(''); setInviteMessage(''); }, - onError: (err: any) => { + onError: (err: unknown) => { toastError('Dispatch Error', err); }, }); diff --git a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx index b58cb10..e9c96d3 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx @@ -19,6 +19,7 @@ import { ShieldCheck, Building, Send, + Lock, } from 'lucide-react'; import { getProjects, @@ -114,12 +115,15 @@ export const SiteVisitsPage: React.FC = () => { visitForm.reset(); success('Site Visit Logged', 'Field inspection recorded with inspector findings.'); }, - onError: (err: any) => toastError('Logging Failed', err), + onError: (err: unknown) => toastError('Logging Failed', err), }); const updateVisitMutation = useMutation({ mutationFn: (vals: SiteVisitFormValues) => { if (!editingVisit) throw new Error('No visit selected'); + if (Number(editingVisit.status) === ApprovalStatus.Approved) { + throw new Error('Approved site visits are verified and immutable. No modifications are permitted.'); + } return updateSiteVisit({ siteVisitId: editingVisit.id, inspectorName: vals.inspectorName, @@ -135,7 +139,7 @@ export const SiteVisitsPage: React.FC = () => { visitForm.reset(); success('Site Visit Updated', 'Inspection report and verification status updated.'); }, - onError: (err: any) => toastError('Update Failed', err), + onError: (err: unknown) => toastError('Update Failed', err), }); // Filtered & Sorted Visits @@ -165,6 +169,10 @@ export const SiteVisitsPage: React.FC = () => { ]; const handleOpenEdit = async (v: SiteVisit) => { + if (Number(v.status) === ApprovalStatus.Approved) { + toastError('Record Immutable', 'Approved site visits are verified and cannot be edited.'); + return; + } try { const fresh = await getSiteVisit(v.id); setEditingVisit(fresh); @@ -446,13 +454,22 @@ export const SiteVisitsPage: React.FC = () => { > View & Feedback - + {Number(v.status) === ApprovalStatus.Approved ? ( + + + + ) : ( + + )} )) @@ -505,30 +522,40 @@ export const SiteVisitsPage: React.FC = () => { {editingVisit && (
- + {Number(editingVisit.status) === ApprovalStatus.Approved ? ( +
+ + Approved & Verified (Immutable - Cannot Be Changed) +
+ ) : ( + + )}
)} diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 6a3a3c7..b5a5e0b 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -37,6 +37,7 @@ import { UpdateMilestoneRequest, Award, CreateAwardRequest, + UpdateAwardRequest, Contract, ContractStatus, CreateContractRequest, @@ -869,6 +870,20 @@ export const createAward = async (payload: CreateAwardRequest): Promise = return res.data; }; +export const updateAward = async (payload: UpdateAwardRequest): Promise => { + const numericAwardId = toIntegerId(payload.awardId); + const numericOrgId = toIntegerId(payload.organisationId); + const numericProgId = toIntegerId(payload.programmeId); + + await apiClient.put('/v1/api/grants/awards', { + awardId: numericAwardId, + organisationId: numericOrgId, + programmeId: numericProgId, + amount: Number(payload.amount), + ...(payload.status !== undefined ? { status: Number(payload.status) } : {}), + }); +}; + export const fetchHashId = async (id64: number | string): Promise => { try { const res = await apiClient.get(`/v1/api/tools/hashid/${id64}`); diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index f870d29..64b5c93 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -257,8 +257,10 @@ export enum UserRole { } export interface Role { - id: string; - name: string; + id?: string; + name?: string; + Id?: string; + Name?: string; } export interface Claim { @@ -361,6 +363,9 @@ export interface Organisation { registrationNo: string | null; email: string | null; userId?: string | null; + UserId?: string | null; + organisationUser?: string | null; + OrganisationUser?: string | null; idNumber?: string | null; type: OrganisationType; status: ActivityStatus; @@ -640,6 +645,14 @@ export interface CreateAwardRequest { fundingPeriod?: string; } +export interface UpdateAwardRequest { + awardId: number | string; + organisationId: number | string; + programmeId: number | string; + amount: number; + status?: ApprovalStatus; +} + export interface Contract { id: number | string; tenantId?: number | string;