Compare commits
85
Commits
937a5595d8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c3201c58 | ||
|
|
a8daea830f | ||
|
|
5be7c8c56a | ||
|
|
d24b0dafb1 | ||
|
|
cd47a2b93a | ||
|
|
c11535ebfc | ||
|
|
129d10c7d1 | ||
|
|
9f132eeb29 | ||
|
|
80c798e5e6 | ||
|
|
2690b3230e | ||
|
|
155a379ab4 | ||
|
|
f56cb70b55 | ||
|
|
bb77a4dc9a | ||
|
|
5ac317cbe3 | ||
|
|
0bcc3484dc | ||
|
|
fddf400ddd | ||
|
|
a1b8410bc2 | ||
|
|
cb0da32ce4 | ||
|
|
70e286670e | ||
|
|
c6075a5c39 | ||
|
|
4e1211a1e1 | ||
|
|
c8e54815ef | ||
|
|
31d775c917 | ||
|
|
78d147b002 | ||
|
|
07018c3ce4 | ||
|
|
db08bbf162 | ||
|
|
ac357285c3 | ||
|
|
a4927f9742 | ||
|
|
fac88f3471 | ||
|
|
aeb7542cfe | ||
|
|
9724bbac24 | ||
|
|
deb21dbb9a | ||
|
|
98fe1e3e3d | ||
|
|
48edfa1cbb | ||
|
|
d9b65010cf | ||
|
|
3e1d8bc1cc | ||
|
|
bc441c1255 | ||
|
|
17a4149a73 | ||
|
|
6e786ca796 | ||
|
|
25e251aa79 | ||
|
|
a731c979bb | ||
|
|
90631d22ae | ||
|
|
17a99ff084 | ||
|
|
010f2d5eaa | ||
|
|
29a11f0e0b | ||
|
|
b8f5ebf69a | ||
|
|
385c76510b | ||
|
|
631a0b7a9a | ||
|
|
957e75a853 | ||
|
|
1233f15b91 | ||
|
|
8a06a86eb3 | ||
|
|
c43e18f184 | ||
|
|
f8f826979d | ||
|
|
d912c5c6c3 | ||
|
|
20bdd8b209 | ||
|
|
c4a0469bae | ||
|
|
bf384d7e6a | ||
|
|
957d2210a9 | ||
|
|
a3f461478c | ||
|
|
16fb6b9434 | ||
|
|
7220700bac | ||
|
|
cc62696017 | ||
|
|
3316fc0275 | ||
|
|
d4598e0c6c | ||
|
|
8f58e15f0f | ||
|
|
43656e848a | ||
|
|
9ed210613e | ||
|
|
3a3245b643 | ||
|
|
5050c901bc | ||
|
|
fdaf12a183 | ||
|
|
46c6ddcc80 | ||
|
|
6cdd03a02f | ||
|
|
7071c404f0 | ||
|
|
1d1060d031 | ||
|
|
cb89817d88 | ||
|
|
b0c89362d0 | ||
|
|
6ff016e7ec | ||
|
|
dee9ccf0b1 | ||
|
|
d45a89ab67 | ||
|
|
d9b3e80f7c | ||
|
|
3fda1ac5db | ||
|
|
efc63ca398 | ||
|
|
299d6ff4dc | ||
|
|
8688c752d6 | ||
|
|
9835121fd9 |
@@ -44,6 +44,7 @@
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
<Using Include="Microsoft.Extensions.Options" />
|
||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||
<Using Include="Microsoft.FeatureManagement" />
|
||||
|
||||
<!-- Domain Constants -->
|
||||
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
|
||||
|
||||
+56
-55
@@ -12,67 +12,68 @@
|
||||
<PackageVersion Include="Scrutor" Version="7.0.0" />
|
||||
|
||||
<!-- Microsoft Extensions & Configuration -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.11.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.8.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
|
||||
|
||||
<!-- Security, Authentication & Identity -->
|
||||
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.2.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.3.0" />
|
||||
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.3.0" />
|
||||
<PackageVersion Include="IdentityModel" Version="6.2.0" />
|
||||
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
||||
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
||||
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||
<PackageVersion Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="KubernetesClient" Version="21.0.8" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.14" />
|
||||
|
||||
<!-- Upgraded to latest non-vulnerable version -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
|
||||
|
||||
<!-- Resilience -->
|
||||
<PackageVersion Include="Polly" Version="8.8.0" />
|
||||
<PackageVersion Include="Polly.Extensions" Version="8.8.0" />
|
||||
<PackageVersion Include="Polly" Version="8.8.1" />
|
||||
<PackageVersion Include="Polly.Extensions" Version="8.8.1" />
|
||||
|
||||
<!-- API, Web & Documentation -->
|
||||
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="15.2.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.4" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.14" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="16.4.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.3" />
|
||||
|
||||
<!-- Database & Entity Framework Core -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.14" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.14" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
|
||||
<!-- Quartz Scheduler -->
|
||||
<PackageVersion Include="Quartz" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.AspNetCore" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Plugins" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
|
||||
<PackageVersion Include="Quartz" Version="4.6.2" />
|
||||
<PackageVersion Include="Quartz.AspNetCore" Version="4.6.2" />
|
||||
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.6.2" />
|
||||
<PackageVersion Include="Quartz.Plugins" Version="4.6.2" />
|
||||
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.6.2" />
|
||||
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.6.2" />
|
||||
|
||||
<!-- Health Checks -->
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||
@@ -83,17 +84,17 @@
|
||||
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||
|
||||
<!-- Observability & OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.20.1" />
|
||||
|
||||
<!-- Email & Messaging -->
|
||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
||||
<PackageVersion Include="MimeKit" Version="4.18.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.2" />
|
||||
<PackageVersion Include="MimeKit" Version="4.18.2" />
|
||||
|
||||
<!-- CQRS & Mediator -->
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
@@ -101,17 +102,17 @@
|
||||
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
||||
|
||||
<!-- Cloud Storage -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.102.7" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.7" />
|
||||
|
||||
<!-- Analyzers -->
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.259" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.317" />
|
||||
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class CreateNotificationEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/communications/notifications", async (CreateNotificationRequest request,
|
||||
HttpContext context, ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateNotificationAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Created(),
|
||||
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Creates a new in-app or outbound notification record")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(CreateNotificationEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetUserNotificationsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/communications/notifications/user/{userId:guid}", async (Guid userId,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(userId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems()),
|
||||
})
|
||||
.WithDescription("Retrieves all notifications targeted at a specific user")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetUserNotificationsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Communications)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/communications/notifications/inbox", async (HttpContext context,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var notifications } => Results.Ok(notifications),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems()),
|
||||
})
|
||||
.WithDescription("Retrieves all in-app and system notifications targeted at the authenticated caller")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName($"{typeof(GetUserNotificationsEndpoint).ToEndpointName()}Inbox")
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Communications)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class UpdateNotificationEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPut("api/communications/notifications", async (UpdateNotificationRequest request,
|
||||
ICommunicationsService service, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateNotificationAsync(request, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.NoContent(),
|
||||
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())
|
||||
})
|
||||
.WithDescription("Updates a notification status, delivery timestamp, or error state")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(UpdateNotificationEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
|
||||
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -12,7 +12,7 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/beneficiaris/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
@@ -31,4 +31,3 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -12,15 +12,7 @@ public sealed class GetOutcomeEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/outcomes/{outcomeId}", async (string outcomeId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
builder.MapGet("api/projects/outcome/{outcomeId}", HandleGetAsync)
|
||||
.WithDescription("Gets a specific project outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName())
|
||||
@@ -32,5 +24,28 @@ public sealed class GetOutcomeEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/outcomes/single/{outcomeId}", HandleGetAsync)
|
||||
.WithDescription("Gets a specific project outcome")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName("Single"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetAsync(string outcomeId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -12,15 +12,7 @@ public sealed class GetOutcomesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/outcomes/{beneficiatyId}", async (string beneficiatyId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomesAsync(beneficiatyId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
builder.MapGet("api/projects/beneficiaries/{beneficiaryId}/outcomes", HandleGetAsync)
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName())
|
||||
@@ -32,5 +24,41 @@ public sealed class GetOutcomesEndpoint : IEndpoint
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/beneficiaris/{beneficiaryId}/outcomes", HandleGetAsync)
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("TypoAlias"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
|
||||
builder.MapGet("api/projects/outcomes/{beneficiaryId}", HandleGetAsync)
|
||||
.WithDescription("Gets project beneficiary outcomes")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("Legacy"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetAsync(string beneficiaryId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetOutcomesAsync(beneficiaryId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetProjectOutcomesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/{projectId}/outcomes", async (string projectId, IProjectService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetProjectOutcomesAsync(projectId, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets project outcomes for all beneficiaries in a project")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetProjectOutcomesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Outcome[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Projects)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
@@ -13,12 +13,18 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
||||
{
|
||||
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
||||
IUserService service, CancellationToken cancellationToken = default) =>
|
||||
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
||||
{
|
||||
var (_, role) = context.User.GetUserRole();
|
||||
if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
|
||||
return Results.Forbid();
|
||||
|
||||
return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, 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())
|
||||
};
|
||||
})
|
||||
.WithDescription("Lock user session")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
@@ -26,6 +32,7 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
|
||||
@@ -11,8 +11,8 @@ public class SyncUserEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
|
||||
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
|
||||
builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
|
||||
@@ -4,7 +4,6 @@ using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Health;
|
||||
using PostFundManagement.Domain.Mediator;
|
||||
using PostFundManagement.Infrastructure.Database.Services;
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -45,7 +44,7 @@ builder.Services.AddSkorubaApiService(builder.Configuration);
|
||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
|
||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
|
||||
|
||||
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
|
||||
builder.Services.AddQuartzSchedulerClient(DefaultSchedulerName, builder.Configuration);
|
||||
builder.Services.AddEmailServices(builder.Configuration);
|
||||
builder.Services.AddHashServices(builder.Configuration);
|
||||
builder.Services.AddGarageS3(builder.Configuration);
|
||||
@@ -116,7 +115,29 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(CorsPolicy, policy =>
|
||||
{
|
||||
policy.WithOrigins(allowedOrigins)
|
||||
policy.SetIsOriginAllowed(origin =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(origin)) return false;
|
||||
|
||||
var clean = origin.TrimEnd('/');
|
||||
|
||||
if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
var host = new Uri(origin).Host;
|
||||
if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { /* eat all exceptions */ }
|
||||
|
||||
return false;
|
||||
})
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
@@ -125,12 +146,7 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
|
||||
var scheduler = await schedulerFactory.GetScheduler();
|
||||
|
||||
if (scheduler!.Status != SchedulerStatus.Running)
|
||||
await scheduler.Start();
|
||||
|
||||
app.UseCors(CorsPolicy);
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"EnableServiceCaching": true,
|
||||
"CorsOrigins": [
|
||||
"https://partner.ifri.khongisa.co.za",
|
||||
"https://console.ifri.khongisa.co.za",
|
||||
"https://localhost:7208"
|
||||
],
|
||||
@@ -31,13 +32,6 @@
|
||||
"Microsoft.EntityFrameworkCore.Update": "Error"
|
||||
}
|
||||
},
|
||||
"EmailProcessorCron": "0 */5 * * * ?",
|
||||
"Email": {
|
||||
"SmtpServer": "smtp.example.com",
|
||||
"SmtpPort": 587,
|
||||
"SenderEmail": "noreply@example.com",
|
||||
"SenderName": "Example"
|
||||
},
|
||||
"HasherSettings": {
|
||||
"MinHashLength": 11
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Application.Services.Communications;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -25,12 +25,14 @@ public interface ICommunicationsService
|
||||
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Evidence;
|
||||
using PostFundManagement.Application.Services.Evidence;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -15,6 +15,10 @@ public interface IEvidenceService
|
||||
[Audit]
|
||||
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Application.Services.Projects;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Models;
|
||||
@@ -28,6 +28,10 @@ public interface IProjectService
|
||||
[Audit]
|
||||
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
[Audit]
|
||||
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -138,6 +142,9 @@ public interface IProjectService
|
||||
[Log]
|
||||
Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class Quartz
|
||||
services.AddQuartz(config =>
|
||||
{
|
||||
config.UseSimpleTypeLoader();
|
||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
|
||||
config.UseThreadPool<global::Quartz.Impl.ZeroSizeThreadPool>();
|
||||
config.UseTimeZoneConverter();
|
||||
|
||||
config.UsePersistentStore(storage =>
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Castle.DynamicProxy;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -152,6 +152,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
||||
return Result.Ok(cached.FromJson<Notification[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(userId, cancellationToken);
|
||||
|
||||
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.SendInviteAsync(request, userId, cancellationToken);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -94,6 +94,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var initialStatus = request.Status ?? (request.Platform == NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued);
|
||||
|
||||
context.Notifications.Add(new Domain.Entities.Notification
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -103,7 +105,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
Subject = request.Subject,
|
||||
Message = request.Message,
|
||||
Platform = request.Platform,
|
||||
Status = NotificationStatus.Queued,
|
||||
Status = initialStatus,
|
||||
TenantId = tenantContext.TenantId
|
||||
});
|
||||
|
||||
@@ -116,16 +118,17 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var changedTotal = await context.Notifications
|
||||
.Where(n => n.Id == request.NotificationId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Destination, request.Destination)
|
||||
.SetProperty(u => u.SentAt, request.SentAt)
|
||||
.SetProperty(u => u.Error, request.Error)
|
||||
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
var entity = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(NotFoundError.Create($"Notification #{request.NotificationId} not found"));
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
|
||||
entity.Status = request.Status;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
if (request.SentAt.HasValue) entity.SentAt = request.SentAt.Value;
|
||||
if (!string.IsNullOrWhiteSpace(request.Destination)) entity.Destination = request.Destination;
|
||||
if (!string.IsNullOrWhiteSpace(request.Error)) entity.Error = request.Error;
|
||||
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support"));
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||
@@ -147,6 +150,19 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var notifications = await context.Notifications.AsNoTracking()
|
||||
.Where(n => n.Recipient == userId)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Communications;
|
||||
|
||||
public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination,
|
||||
[Required] NotificationStatus Status,
|
||||
[Required] DateTime SentAt,
|
||||
[MaxLength(TextLength)] string? Error);
|
||||
public sealed record UpdateNotificationRequest([Required] long NotificationId,
|
||||
string? Destination = null,
|
||||
[Required] NotificationStatus Status = NotificationStatus.Read,
|
||||
DateTime? SentAt = null,
|
||||
[MaxLength(TextLength)] string? Error = null);
|
||||
|
||||
public sealed record CreateNotificationRequest([Required] long OrganisationId,
|
||||
[Required] Guid Recipient,
|
||||
[Required] NotificationPlatform Platform,
|
||||
[Required][MaxLength(LabelLength)] string? Subject,
|
||||
[Required][MaxLength(LabelLength)] string? Destination,
|
||||
[Required][MaxLength(TextLength)] string Message);
|
||||
[Required][MaxLength(LargeTextLength)] string Message,
|
||||
NotificationStatus? Status = null);
|
||||
|
||||
public sealed record InviteRequest([Required] long OrganisationId,
|
||||
[Required] long InvitedOrganisationId,
|
||||
[Required] long AwardId,
|
||||
[Required] long ContractId,
|
||||
[Required] long ProjectId,
|
||||
[Required] Guid Recipient,
|
||||
[Required][MaxLength(LabelLength)] string Message,
|
||||
[Required] NotificationPlatform Platform);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -189,6 +189,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateEvidenceAsync(request, userId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -50,6 +50,9 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
}));
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await RecordIndicatorActualAmountAsync(indicatorIdHash, amount, null, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
|
||||
@@ -63,6 +66,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(f => f.ActualAmount, amount)
|
||||
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
|
||||
.SetProperty(f => f.CreatedBy, userId), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator."));
|
||||
@@ -130,14 +134,21 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
if (request.ActivityId.HasValue && !await context.Activities.AnyAsync(a => a.Id == request.ActivityId.Value && a.ProjectId == request.ProjectId, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Activity not found"));
|
||||
|
||||
context.Indicators.Add(new Domain.Entities.Indicator
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
ActivityId = request.ActivityId,
|
||||
|
||||
Name = request.Name,
|
||||
UnitOfMeasure = request.UnitOfMeasure,
|
||||
BaselineAmount = request.BaselineAmount,
|
||||
TargetAmount = request.TargetAmount,
|
||||
ActualAmount = request.BaselineAmount,
|
||||
TargetCost = request.TargetCost,
|
||||
ActualCost = 0,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Evidence;
|
||||
|
||||
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
|
||||
[Required] EvidenceType EvidenceType,
|
||||
[Required] ApprovalStatus Status);
|
||||
|
||||
public sealed record RecordActualRequest([Required] decimal ActualAmount);
|
||||
public sealed record RecordActualRequest([Required] decimal ActualAmount, decimal? ActualCost = null);
|
||||
|
||||
public sealed record CreateMilestoneRequest([Required] long ProjectId,
|
||||
[Required] DateTime DueAt,
|
||||
@@ -18,4 +18,6 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required] UnitOfMeasure UnitOfMeasure,
|
||||
[Required] decimal BaselineAmount,
|
||||
[Required] decimal TargetAmount);
|
||||
[Required] decimal TargetAmount,
|
||||
long? ActivityId = null,
|
||||
decimal TargetCost = 0);
|
||||
|
||||
@@ -94,6 +94,16 @@ public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> con
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var disbursement = await context.Disbursements.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == decodeResult.Value, cancellationToken);
|
||||
if (disbursement is null)
|
||||
return Result.Fail(NotFoundError.Create("Disbursement not found."));
|
||||
|
||||
var isAwardApproved = await context.Awards
|
||||
.AnyAsync(a => a.ProjectId == disbursement.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
|
||||
if (!isAwardApproved)
|
||||
return Result.Fail(ValidationError.Create("Cannot approve disbursement: The project's linked award is not approved or does not exist."));
|
||||
|
||||
var changedTotal = await context.Disbursements.Where(d => d.Id == decodeResult.Value)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(f => f.Status, ApprovalStatus.Approved)
|
||||
|
||||
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||
|
||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||
|
||||
var changedTotal = await context.Organisations
|
||||
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
||||
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
||||
.SetProperty(u => u.Type, request.Type)
|
||||
.SetProperty(u => u.UserId, request.OrganisationUser)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -12,6 +12,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
||||
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateActivityAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.UpdateActivityAsync(request, userId, cancellationToken);
|
||||
|
||||
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
|
||||
|
||||
@@ -372,6 +375,30 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
|
||||
return Result.Ok(cached.FromJson<Outcome[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||
{
|
||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
||||
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
||||
|
||||
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
|
||||
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var result = await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
|
||||
|
||||
if (result.IsFailed) return result;
|
||||
|
||||
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
|
||||
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
return Result.Ok(cached.FromJson<Outcome[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
@@ -123,6 +123,29 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
var approvedBudget = await context.Budgets
|
||||
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
|
||||
.SumAsync(b => b.Amount, cancellationToken);
|
||||
|
||||
if (approvedBudget == 0)
|
||||
{
|
||||
approvedBudget = await context.Awards
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
}
|
||||
|
||||
if (approvedBudget > 0)
|
||||
{
|
||||
var existingTotal = await context.Activities
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
|
||||
if (existingTotal + request.Amount > approvedBudget)
|
||||
{
|
||||
return Result.Fail(CreateError.Create($"Activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, already allocated: R {existingTotal:N2}, remaining: R {(approvedBudget - existingTotal):N2}."));
|
||||
}
|
||||
}
|
||||
|
||||
context.Activities.Add(new Domain.Entities.Activity
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
@@ -141,6 +164,54 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreateError.Create("No record was created."));
|
||||
}));
|
||||
|
||||
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var activity = await context.Activities.FirstOrDefaultAsync(a => a.Id == request.ActivityId, cancellationToken);
|
||||
if (activity is null)
|
||||
return Result.Fail(NotFoundError.Create("Activity not found"));
|
||||
|
||||
if (request.MilestoneId.HasValue && !await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Milestone not found"));
|
||||
|
||||
var approvedBudget = await context.Budgets
|
||||
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
|
||||
.SumAsync(b => b.Amount, cancellationToken);
|
||||
|
||||
if (approvedBudget == 0)
|
||||
{
|
||||
approvedBudget = await context.Awards
|
||||
.Where(a => a.ProjectId == request.ProjectId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
}
|
||||
|
||||
if (approvedBudget > 0)
|
||||
{
|
||||
var otherTotal = await context.Activities
|
||||
.Where(a => a.ProjectId == request.ProjectId && a.Id != request.ActivityId)
|
||||
.SumAsync(a => a.Amount, cancellationToken);
|
||||
|
||||
if (otherTotal + request.Amount > approvedBudget)
|
||||
{
|
||||
return Result.Fail(UpdateError.Create($"Updated activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, other allocated: R {otherTotal:N2}, remaining: R {(approvedBudget - otherTotal):N2}."));
|
||||
}
|
||||
}
|
||||
|
||||
activity.Title = request.Title.Humanize(LetterCasing.Title).Trim();
|
||||
activity.Description = request.Description;
|
||||
activity.Category = request.Category.Humanize(LetterCasing.Title).Trim();
|
||||
activity.Amount = request.Amount;
|
||||
activity.MilestoneId = request.MilestoneId;
|
||||
activity.PerformedAt = request.PerformedAt;
|
||||
activity.UpdatedBy = userId;
|
||||
activity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
|
||||
UpdateError.Create("No changes detected, update cancelled."));
|
||||
}));
|
||||
|
||||
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
|
||||
{
|
||||
@@ -473,8 +544,9 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = userId,
|
||||
ProjectId = request.ProjectId,
|
||||
BeneficiaryId = request.BeneficiaryId,
|
||||
Requirements = request.Requirements,
|
||||
Status = ApprovalStatus.Pending,
|
||||
Status = request.Status,
|
||||
TenantId = tenantContext.TenantId,
|
||||
Title = request.Title.Humanize(LetterCasing.Title).Trim()
|
||||
});
|
||||
@@ -492,7 +564,10 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Title, request.Title.Humanize(LetterCasing.Title).Trim())
|
||||
.SetProperty(u => u.Requirements, request.Requirements)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
.SetProperty(u => u.BeneficiaryId, request.BeneficiaryId)
|
||||
.SetProperty(u => u.Status, request.Status)
|
||||
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.SetProperty(u => u.VerifiedBy, userId), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
|
||||
}));
|
||||
@@ -697,6 +772,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
CreatedBy = userId,
|
||||
Description = request.Description,
|
||||
Name = request.Name.Humanize(LetterCasing.Title).Trim(),
|
||||
Status = request.Status,
|
||||
TenantId = tenantContext.TenantId
|
||||
});
|
||||
|
||||
@@ -712,7 +788,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
.Where(o => o.Id == request.OutcomeId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
.SetProperty(u => u.Description, request.Description)
|
||||
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()), cancellationToken);
|
||||
.SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("No records changed"));
|
||||
}));
|
||||
@@ -736,6 +813,25 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
||||
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
|
||||
|
||||
if (decodeResult.IsFailed)
|
||||
return Result.Fail<Outcome[]>(IdDecodeError.Create()
|
||||
.CausedBy(decodeResult.Errors));
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var outcomes = await context.Outcomes.AsNoTracking()
|
||||
.Where(o => o.Beneficiary!.ProjectId == decodeResult.Value)
|
||||
.OrderByDescending(o => o.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Projects;
|
||||
|
||||
@@ -15,11 +15,13 @@ public sealed record CreateSiteVisitRequest([Required] long ProjectId,
|
||||
|
||||
public sealed record UpdateOutcomeRequest([Required] long OutcomeId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(TextLength)] string Description);
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
[Required] ComplianceStatus Status = ComplianceStatus.NotMet);
|
||||
|
||||
public sealed record CreateOutcomeRequest([Required] long BeneficiaryId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(TextLength)] string Description);
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
ComplianceStatus Status = ComplianceStatus.NotMet);
|
||||
|
||||
public sealed record UpdateIssueRequest([Required] long IssueId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
@@ -53,11 +55,14 @@ public sealed record CreateBeneficiaryRequest([Required] long ProjectId,
|
||||
public sealed record UpdateComplianceItemRequest([Required] long ComplianceItemId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Requirements,
|
||||
[Required] ApprovalStatus Status);
|
||||
[Required] ComplianceStatus Status,
|
||||
long? BeneficiaryId = null);
|
||||
|
||||
public sealed record CreateComplianceItemRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Requirements);
|
||||
[Required][MaxLength(TextLength)] string Requirements,
|
||||
ComplianceStatus Status = ComplianceStatus.NotMet,
|
||||
long? BeneficiaryId = null);
|
||||
|
||||
public sealed record UpdateChangeRequest([Required] long ChangeRequestId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
@@ -106,3 +111,12 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
DateTime? PerformedAt);
|
||||
|
||||
public sealed record UpdateActivityRequest([Required] long ActivityId,
|
||||
[Required] long ProjectId,
|
||||
long? MilestoneId,
|
||||
[Required][MaxLength(LabelLength)] string Category,
|
||||
[Required] decimal Amount,
|
||||
[Required][MaxLength(LabelLength)] string Title,
|
||||
[Required][MaxLength(TextLength)] string Description,
|
||||
DateTime? PerformedAt);
|
||||
|
||||
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
||||
|
||||
int[] decoded = hasher.Decode(hash);
|
||||
|
||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
|
||||
.ToResult(decoded[0]);
|
||||
if (decoded.Length <= 0)
|
||||
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
|
||||
|
||||
return Result.Ok(decoded[0]);
|
||||
}
|
||||
|
||||
public Result<long> DecodeLongIdHash(string hash)
|
||||
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
||||
|
||||
long[] decoded = hasher.DecodeLong(hash);
|
||||
|
||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
|
||||
.ToResult(decoded[0]);
|
||||
if (decoded.Length <= 0)
|
||||
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
|
||||
|
||||
return Result.Ok(decoded[0]);
|
||||
}
|
||||
|
||||
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
||||
|
||||
@@ -12,7 +12,6 @@ APP_URL="MY_APP_URL"
|
||||
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
||||
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
||||
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
||||
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
|
||||
|
||||
# PFM Backend API Base URL
|
||||
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
||||
|
||||
@@ -26,15 +26,12 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
|
||||
# 4. OIDC Client ID
|
||||
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||
|
||||
# 5. OIDC Client Secret
|
||||
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
||||
|
||||
# 6. OIDC Scope
|
||||
# 5. OIDC Scope
|
||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||
|
||||
# 7. Hasher Settings
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||
|
||||
if [ -d "$TARGET_DIR" ]; then
|
||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||
@@ -51,14 +48,12 @@ window.__RUNTIME_CONFIG__ = {
|
||||
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||
HASHER_SALT: "${HASHER_SALT}",
|
||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
HASHER_SALT: "${HASHER_SALT}",
|
||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||
ApiSettings: {
|
||||
BaseUrl: "${API_BASE_URL}"
|
||||
},
|
||||
@@ -66,7 +61,6 @@ window.__RUNTIME_CONFIG__ = {
|
||||
Authority: "${OIDC_AUTHORITY}",
|
||||
Audience: "${OIDC_AUDIENCE}",
|
||||
ClientId: "${OIDC_CLIENT_ID}",
|
||||
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
||||
Scope: "${OIDC_SCOPE}"
|
||||
},
|
||||
HasherSettings: {
|
||||
@@ -86,7 +80,6 @@ EOF
|
||||
"Authority": "${OIDC_AUTHORITY}",
|
||||
"Audience": "${OIDC_AUDIENCE}",
|
||||
"ClientId": "${OIDC_CLIENT_ID}",
|
||||
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
||||
"Scope": "${OIDC_SCOPE}"
|
||||
},
|
||||
"HasherSettings": {
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
"Authority": "",
|
||||
"Audience": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"Scope": ""
|
||||
},
|
||||
"HasherSettings": {
|
||||
"Salt": "",
|
||||
"MinHashLength": ""
|
||||
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
"MinHashLength": "11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
// Development and fallback runtime configuration.
|
||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
HASHER_MIN_LENGTH: "11",
|
||||
VITE_HASHER_MIN_LENGTH: "11",
|
||||
HasherSettings: {
|
||||
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
MinHashLength: "11"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,10 +14,12 @@ const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ defa
|
||||
const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage })));
|
||||
const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage })));
|
||||
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
|
||||
const CompliancePage = lazy(() => import('./pages/CompliancePage').then(m => ({ default: m.CompliancePage })));
|
||||
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
|
||||
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
||||
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
const InboxPage = lazy(() => import('./pages/InboxPage').then(m => ({ default: m.InboxPage })));
|
||||
|
||||
const RouteLoading = () => (
|
||||
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
|
||||
@@ -132,6 +134,22 @@ export default function App() {
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/compliance"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="compliance">
|
||||
<CompliancePage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/compliance"
|
||||
element={
|
||||
<ClaimProtectedRoute claim="compliance">
|
||||
<CompliancePage />
|
||||
</ClaimProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/grants"
|
||||
element={
|
||||
@@ -164,6 +182,8 @@ export default function App() {
|
||||
</TenantManagementProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/mailbox" element={<InboxPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
isActivationPending,
|
||||
getActivationPendingReason,
|
||||
clearUserSyncInBrowser,
|
||||
setActivationPending,
|
||||
UserSyncResult,
|
||||
} from '../services/userSyncService';
|
||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||
import { encodeHashIdSync } from '../services/toolsService';
|
||||
import { verifyTenantLockout } from '../services/tenantLockoutService';
|
||||
import { encodeHashIdSync } from '../utils/hashids';
|
||||
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
|
||||
import {
|
||||
onSessionExpired,
|
||||
triggerSessionExpired,
|
||||
@@ -307,11 +308,6 @@ export function resetUserManager(): void {
|
||||
userManagerInstance = null;
|
||||
}
|
||||
|
||||
// Automatically reset UserManager when runtime configuration is updated
|
||||
onConfigChanged(() => {
|
||||
resetUserManager();
|
||||
});
|
||||
|
||||
export function getUserManager(): UserManager {
|
||||
if (!userManagerInstance && typeof window !== 'undefined') {
|
||||
const sec = getSecuritySettings();
|
||||
@@ -320,7 +316,7 @@ export function getUserManager(): UserManager {
|
||||
userManagerInstance = new UserManager({
|
||||
authority: sec.Authority,
|
||||
client_id: sec.ClientId,
|
||||
client_secret: sec.ClientSecret,
|
||||
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
|
||||
redirect_uri: `${origin}/dashboard`,
|
||||
post_logout_redirect_uri: `${origin}/`,
|
||||
response_type: 'code',
|
||||
@@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||
|
||||
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
||||
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
const parsedTenantId = extractTenantId(mergedClaims, token);
|
||||
let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
let parsedTenantId = extractTenantId(mergedClaims, token);
|
||||
setTenantId(parsedTenantId);
|
||||
|
||||
const profile: AuthUserProfile = {
|
||||
@@ -458,7 +454,38 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// Clean up code and state from URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
||||
// Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
|
||||
try {
|
||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||
if (resolved.primaryRole) {
|
||||
profile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
profile.tenant = resolved.tenant;
|
||||
tenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
profile.tenant_id = resolved.tenant_id;
|
||||
parsedTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...profile });
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Verify tenant lockout now that user is synced and authenticated
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
parsedTenantId ?? tenantClaimValues.tenantId,
|
||||
tenantClaim
|
||||
@@ -480,14 +507,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setTenantName(tenantClaim);
|
||||
}
|
||||
|
||||
// Synchronize authenticated user profile with backend directory
|
||||
try {
|
||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
@@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
|
||||
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||
if (resolved.primaryRole) {
|
||||
profile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
profile.tenant = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
profile.tenant_id = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...profile });
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
@@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
setActivationError(cleanMsg);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setSub(storedSub);
|
||||
}
|
||||
|
||||
const storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||
let storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||
setTenantId(storedTenantId);
|
||||
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
||||
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||
|
||||
let resolvedUser: AuthUserProfile | null = null;
|
||||
if (storedUser) {
|
||||
@@ -588,9 +589,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||
setIsLoading(false);
|
||||
|
||||
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
|
||||
// Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
|
||||
(async () => {
|
||||
try {
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
|
||||
// Step 1: Sync user account FIRST
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Retrieve user roles, role claims, and user claims
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(
|
||||
storedSub || '',
|
||||
parsedClaims,
|
||||
resolvedUser || {}
|
||||
);
|
||||
if (resolvedUser) {
|
||||
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
||||
if (resolved.tenant) {
|
||||
resolvedUser.tenant = resolved.tenant;
|
||||
storedTenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
resolvedUser.tenant_id = resolved.tenant_id;
|
||||
storedTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Verify tenant lockout now that user is verified active
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
storedTenantId ?? tenantClaimValues.tenantId,
|
||||
storedTenantClaim
|
||||
@@ -612,22 +657,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
}
|
||||
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
@@ -639,27 +668,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
|
||||
const resolved = await resolveUserRolesAndClaims(
|
||||
storedSub || '',
|
||||
parsedClaims,
|
||||
resolvedUser || {}
|
||||
);
|
||||
if (resolvedUser) {
|
||||
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
||||
if (resolved.tenant) {
|
||||
resolvedUser.tenant = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
resolvedUser.tenant_id = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
@@ -668,7 +676,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
setActivationError(cleanMsg);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -687,12 +697,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
||||
try {
|
||||
// Re-verify tenant status via GetTenant as part of the lockout resolution
|
||||
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
|
||||
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
|
||||
await loadRuntimeConfig();
|
||||
clearTenantLockoutCache();
|
||||
|
||||
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
|
||||
// Step 1: Proceed with user sync FIRST (/v1/api/integration/sync)
|
||||
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
||||
if (result.isUnauthorized) {
|
||||
await logout();
|
||||
return result;
|
||||
}
|
||||
if (result.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(result.errorMessage || null);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 2: Refresh user roles and claims from backend
|
||||
let currentTenantId: number | string | null = tenantId;
|
||||
let currentTenantClaim: string | undefined =
|
||||
(claims.tenant as string) || user?.tenant || undefined;
|
||||
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
|
||||
const updatedProfile = user ? { ...user } : null;
|
||||
if (updatedProfile && resolved.primaryRole) {
|
||||
updatedProfile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
currentTenantClaim = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
currentTenantId = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
|
||||
}
|
||||
if (updatedProfile) {
|
||||
setUser(updatedProfile);
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
|
||||
// Step 3: Re-verify tenant status via GetTenant now that user is synced
|
||||
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||
const effectiveTenantId =
|
||||
currentTenantId ??
|
||||
result.data?.tenantId ??
|
||||
result.data?.organisationId ??
|
||||
tenantClaimValues.tenantId;
|
||||
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
|
||||
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
effectiveTenantId,
|
||||
effectiveTenantClaim,
|
||||
true
|
||||
);
|
||||
if (!tenantLockoutResult.isActive) {
|
||||
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
||||
setIsPendingActivation(true);
|
||||
@@ -705,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
};
|
||||
}
|
||||
|
||||
// Tenant is verified Active: proceed with user sync
|
||||
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
|
||||
if (result.isUnauthorized) {
|
||||
await logout();
|
||||
return result;
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
}
|
||||
if (result.success) {
|
||||
|
||||
setActivationPending(false);
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
} else if (result.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(result.errorMessage || null);
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
await logout();
|
||||
return { success: false, isUnauthorized: true };
|
||||
}
|
||||
const msg = err?.message || 'Sync failed';
|
||||
const rawMsg = err?.message || 'Sync failed';
|
||||
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(msg);
|
||||
|
||||
@@ -11,6 +11,7 @@ export type RoleClaim =
|
||||
| 'beneficiaries'
|
||||
| 'change_management'
|
||||
| 'site_visits'
|
||||
| 'compliance'
|
||||
| 'settings_invites'
|
||||
| 'settings_users'
|
||||
| 'settings_auditlog'
|
||||
@@ -36,6 +37,7 @@ export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
|
||||
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
|
||||
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
|
||||
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
|
||||
{ claim: 'compliance', label: 'Compliance', description: 'Statutory compliance dashboard, outcome fulfillment, and risk monitoring' },
|
||||
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
|
||||
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
|
||||
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
|
||||
@@ -60,6 +62,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
||||
admin: ['unrestricted'],
|
||||
executive: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'governance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
@@ -68,6 +71,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
||||
],
|
||||
supervisor: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
@@ -79,6 +83,7 @@ export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
||||
],
|
||||
manager: [
|
||||
'dashboard',
|
||||
'compliance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
@@ -391,6 +396,7 @@ export const ClaimProtectedRoute: React.FC<{
|
||||
if (!allowed) {
|
||||
let fallbackPath = '/dashboard';
|
||||
if (canView('dashboard')) fallbackPath = '/dashboard';
|
||||
else if (canView('compliance')) fallbackPath = '/compliance';
|
||||
else if (canView('projects_milestones')) fallbackPath = '/projects';
|
||||
else if (canView('grant_financials')) fallbackPath = '/grants';
|
||||
else if (canView('evidence_risks')) fallbackPath = '/monitoring';
|
||||
|
||||
@@ -115,141 +115,94 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="activation-modal-title"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
|
||||
>
|
||||
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
|
||||
<div
|
||||
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
|
||||
className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Amber accent top bar */}
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
|
||||
{/* Subtle accent top border */}
|
||||
<div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
|
||||
Access Restricted • Lockout Active
|
||||
</span>
|
||||
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||
{errorMessage?.toLowerCase().includes('tenant')
|
||||
? 'Tenant Lockout • Account Inactive'
|
||||
: 'Account Pending Administrator Activation'}
|
||||
<h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
|
||||
{isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
|
||||
{/* Status notification block */}
|
||||
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<span>Identity Verified & Signed In Successfully</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
|
||||
However, your account has not yet been enabled in the system directory by a system administrator.
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
|
||||
If your administrator informs you that your account has been activated, please click the{' '}
|
||||
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{isTenantIssue
|
||||
? 'The assigned tenant partition is currently inactive or restricted.'
|
||||
: 'Your account is authenticated and awaiting administrator enablement.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
|
||||
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
|
||||
<span>Authenticated User Profile</span>
|
||||
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
Lockout Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Name</span>
|
||||
<BrandLogo size={32} showGlow={false} animateOnHover={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* User Profile Summary */}
|
||||
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
|
||||
<span className="font-semibold text-white truncate block">{displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Mail className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Email</span>
|
||||
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
|
||||
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
|
||||
<span className="font-semibold text-slate-200 truncate block">
|
||||
{tenant} ({role})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] text-slate-500 block">Subject ID</span>
|
||||
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
|
||||
<div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
|
||||
<span className="text-slate-300 truncate block">{tenant}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
|
||||
<span className="text-blue-400 font-medium truncate block">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Attempt Feedback / Error Notice */}
|
||||
{/* Feedback notice if sync failed */}
|
||||
{errorMessage && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-rose-300">
|
||||
{errorMessage?.toLowerCase().includes('tenant')
|
||||
? 'Tenant Lockout In Effect'
|
||||
: 'Account Not Activated Yet'}
|
||||
</p>
|
||||
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
|
||||
{syncAttemptCount > 0 && (
|
||||
<p className="text-[11px] text-rose-400/80 pt-1">
|
||||
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-amber-300 block">Notice:</span>
|
||||
<p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center">
|
||||
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={handleNavigateLanding}
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
||||
title="View public landing page. Entering the console again will display this modal until enabled."
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Landing Page</span>
|
||||
@@ -258,8 +211,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
|
||||
title="Sign out of current SSO session"
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out</span>
|
||||
@@ -271,10 +223,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||
disabled={isSyncing}
|
||||
type="button"
|
||||
id="activation-sync-btn"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
<span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,114 +61,57 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
|
||||
aria-modal="true"
|
||||
aria-labelledby="partner-denied-title"
|
||||
aria-describedby="partner-denied-description"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
|
||||
>
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50 my-auto">
|
||||
{/* Glow Header Accent */}
|
||||
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
|
||||
<div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
|
||||
<div className="h-1 w-full bg-rose-600" />
|
||||
|
||||
<div className="p-6 sm:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
|
||||
<Ban className="h-6 w-6 text-rose-400" />
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
|
||||
<Ban className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
|
||||
<Lock className="w-3 h-3" />
|
||||
Restricted Role
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">Security Policy</span>
|
||||
</div>
|
||||
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
|
||||
Access Denied: Partner Role
|
||||
<div>
|
||||
<h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
|
||||
Partner Portal Required
|
||||
</h2>
|
||||
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
||||
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
|
||||
<p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
|
||||
Accounts with the Partner role must access the Partner Portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User & Identity Summary Card */}
|
||||
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium">Account Name</span>
|
||||
<span className="text-white font-semibold">{displayName}</span>
|
||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
|
||||
<span className="text-white font-medium">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{displayEmail && (
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
Email
|
||||
</span>
|
||||
<span className="text-slate-200 font-mono">{displayEmail}</span>
|
||||
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
|
||||
<span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Assigned Role
|
||||
</span>
|
||||
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
|
||||
{role}
|
||||
</span>
|
||||
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
|
||||
<span className="text-rose-400 font-medium">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenant && (
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Tenant Context
|
||||
</span>
|
||||
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub && (
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Fingerprint className="w-3 h-3" />
|
||||
Subject Key
|
||||
</span>
|
||||
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Policy Information */}
|
||||
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
|
||||
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||
Administrative Boundary Notice
|
||||
<p className="text-[11px] text-slate-400 text-center leading-relaxed">
|
||||
The Console is reserved for statutory administrators and programme managers.
|
||||
</p>
|
||||
<p className="text-[11px] text-rose-300/90">
|
||||
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2">
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-rose-400" />
|
||||
<span>Sign Out & Return to Home</span>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign Out & Return Home</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Brand watermark */}
|
||||
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
|
||||
<span>Post Fund Management (PFM)</span>
|
||||
</div>
|
||||
<span>National Security Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
const modalContent = (
|
||||
<div
|
||||
id="session-expired-modal-backdrop"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200 overflow-y-auto"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
role="dialog"
|
||||
@@ -96,27 +96,20 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
>
|
||||
<div
|
||||
id="session-expired-modal-content"
|
||||
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5 my-auto"
|
||||
className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
|
||||
>
|
||||
{/* Subtle decorative top accent bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
|
||||
|
||||
{/* Top bar with close button */}
|
||||
<div className="flex items-start justify-between pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
||||
Session Expired
|
||||
</h3>
|
||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
|
||||
HTTP 401
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
|
||||
<p className="text-xs text-slate-400">Authorization credentials expired</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,60 +118,46 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
aria-label="Dismiss and return to landing page"
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Informative Explanation (non-technical and clear) */}
|
||||
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
|
||||
<div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
|
||||
<p>
|
||||
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed.
|
||||
Your active session has ended to protect account security. Please sign in again to continue working.
|
||||
</p>
|
||||
{reason && (
|
||||
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
|
||||
<div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
|
||||
<span className="break-words">{reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Countdown */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
Returning to landing page in{' '}
|
||||
<strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||
Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||
</span>
|
||||
{isPaused && (
|
||||
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
|
||||
Paused on hover
|
||||
<span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar line */}
|
||||
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
|
||||
<div className="pt-1">
|
||||
<button
|
||||
id="session-expired-return-btn"
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2"
|
||||
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Return to Landing Page Now</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
|
||||
<span>Return to Landing Page</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
|
||||
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
||||
code: z.string().optional(),
|
||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||
status: z.coerce.number().default(ActivityStatus.Active),
|
||||
status: z.coerce.number(),
|
||||
});
|
||||
|
||||
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
|
||||
|
||||
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
|
||||
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
|
||||
name: z.string().min(3, 'Rule name must be at least 3 characters'),
|
||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
||||
version: z.coerce.number().min(1, 'Version must be at least 1').default(1),
|
||||
status: z.coerce.number().default(ActivityStatus.Active),
|
||||
version: z.coerce.number().min(1, 'Version must be at least 1'),
|
||||
status: z.coerce.number(),
|
||||
});
|
||||
|
||||
type RuleFormValues = z.infer<typeof ruleSchema>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
@@ -91,6 +93,13 @@ const navItems: NavItem[] = [
|
||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||
claim: 'site_visits',
|
||||
},
|
||||
{
|
||||
name: 'Compliance',
|
||||
path: '/compliance',
|
||||
icon: ShieldCheck,
|
||||
help: 'Portfolio & programme compliance dashboard, statutory requirements, beneficiary outcome fulfillment, and risk monitoring.',
|
||||
claim: 'compliance',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -114,6 +123,12 @@ const navItems: NavItem[] = [
|
||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||
claim: 'tenant_management',
|
||||
},
|
||||
{
|
||||
name: 'Inbox',
|
||||
path: '/inbox',
|
||||
icon: Inbox,
|
||||
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
path: '/settings',
|
||||
@@ -129,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
|
||||
if (collapsedSections[path] !== undefined) {
|
||||
return collapsedSections[path];
|
||||
}
|
||||
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleSection = (path: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[path]: !isSectionCollapsed(path),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -171,7 +207,6 @@ export const AppLayout: React.FC = () => {
|
||||
useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||
@@ -179,7 +214,6 @@ export const AppLayout: React.FC = () => {
|
||||
queryKey: ['active-tenant-header', tenantId],
|
||||
queryFn: () => getTenant(tenantId!),
|
||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Ensure current logged-in user is registered in the email cache
|
||||
@@ -331,13 +365,16 @@ export const AppLayout: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={hasSub}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
@@ -348,9 +385,25 @@ export const AppLayout: React.FC = () => {
|
||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
</NavLink>
|
||||
{hasSub && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleSection(item.path, e)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
|
||||
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-items under parent item */}
|
||||
{hasSub && (
|
||||
{hasSub && !isCollapsed && (
|
||||
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||
{item.subItems?.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
@@ -518,6 +571,8 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
{/* User Profile and Controls */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Notification Bell in close proximity to username */}
|
||||
<NotificationBell />
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||
@@ -555,6 +610,14 @@ export const AppLayout: React.FC = () => {
|
||||
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/inbox"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Mailbox</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
Inbox,
|
||||
Clock,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
|
||||
import { formatDate } from '../../lib/formatters';
|
||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export const NotificationBell: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { top5Unread, unreadCount, markAsRead, markAllAsRead } = useInAppNotifications();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const leaveTimerRef = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Clear timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (leaveTimerRef.current) {
|
||||
window.clearTimeout(leaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (leaveTimerRef.current) {
|
||||
window.clearTimeout(leaveTimerRef.current);
|
||||
leaveTimerRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (id: number | string) => {
|
||||
await markAsRead(id);
|
||||
setIsOpen(false);
|
||||
navigate(`/inbox?id=${id}`);
|
||||
};
|
||||
|
||||
const handleViewAll = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/inbox');
|
||||
};
|
||||
|
||||
// Format relative timestamp
|
||||
const getRelativeTime = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return formatDate(dateStr);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
id="header-notification-bell-btn"
|
||||
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition cursor-pointer ${
|
||||
isOpen
|
||||
? 'bg-blue-600/30 text-white border border-blue-400/50 shadow-md shadow-blue-500/20'
|
||||
: 'bg-slate-900/80 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 hover:border-slate-600 shadow-xs'
|
||||
}`}
|
||||
title={`Notifications • ${unreadCount} unread`}
|
||||
aria-label="In-App Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
|
||||
{/* Unread Count Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
id="notification-unread-count-badge"
|
||||
className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-rose-600 text-[10px] font-extrabold text-white shadow-lg ring-2 ring-[#0d121f] animate-pulse"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Floating Hover Popover */}
|
||||
{isOpen && (
|
||||
<div
|
||||
id="notification-bell-dropdown"
|
||||
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl p-0 z-50 backdrop-blur-xl animate-in fade-in slide-in-from-top-2 overflow-hidden"
|
||||
>
|
||||
{/* Popover Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/40 via-slate-900/40 to-slate-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-bold text-white tracking-tight">In-App Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-semibold text-rose-300 bg-rose-500/20 border border-rose-500/30 px-1.5 py-0.2 rounded-full">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
||||
title="Mark all notifications as read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<span>Mark read</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 5 Notifications List */}
|
||||
<div className="max-h-80 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{top5Unread.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-slate-200">All caught up!</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
No unread in-app notifications at this time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
top5Unread.map(item => {
|
||||
const relativeTime = getRelativeTime(item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleNotificationClick(item.id)}
|
||||
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex items-start gap-3 relative"
|
||||
>
|
||||
{/* Unread Accent Indicator */}
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0 shadow-sm shadow-blue-500" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<p className="text-xs font-semibold text-white group-hover:text-blue-400 transition truncate">
|
||||
{item.subject || 'In-App Notification'}
|
||||
</p>
|
||||
{relativeTime && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0 font-mono flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{relativeTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional message content.')}
|
||||
</p>
|
||||
|
||||
{item.destination && (
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1 truncate">
|
||||
{item.destination}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-white group-hover:translate-x-0.5 transition shrink-0 mt-1" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popover Footer: Open Inbox */}
|
||||
<div className="p-2.5 border-t border-slate-800 bg-[#090d16]/90 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewAll}
|
||||
id="view-mailbox-btn"
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 hover:text-white border border-blue-500/30 text-xs font-semibold transition cursor-pointer shadow-xs"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5" />
|
||||
<span>Go to Mailbox ({unreadCount} unread)</span>
|
||||
<ChevronRight className="w-3 h-3 ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getAllProgrammes,
|
||||
getProjects,
|
||||
} from '../../services/api';
|
||||
import { toolsService } from '../../services/toolsService';
|
||||
import { toolsService } from '../../utils/hashids';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||
@@ -898,7 +898,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* ProblemDetails Error Alert */}
|
||||
{createApiError && (
|
||||
{Boolean(createApiError) && (
|
||||
<ProblemDetailsAlert
|
||||
error={createApiError}
|
||||
onDismiss={() => setCreateApiError(null)}
|
||||
@@ -1126,7 +1126,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{uploadApiError && (
|
||||
{Boolean(uploadApiError) && (
|
||||
<ProblemDetailsAlert
|
||||
error={uploadApiError}
|
||||
onDismiss={() => setUploadApiError(null)}
|
||||
@@ -1228,7 +1228,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{updateApiError && (
|
||||
{Boolean(updateApiError) && (
|
||||
<ProblemDetailsAlert
|
||||
error={updateApiError}
|
||||
onDismiss={() => setUpdateApiError(null)}
|
||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
||||
<span>Active / Executed</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
|
||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
title="Approved evidence is immutable. Status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Send,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Table,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Edit3,
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
||||
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import {
|
||||
renderMarkdownToHtml,
|
||||
parseMarkdownToHtml,
|
||||
isHtmlContent,
|
||||
stripMarkdown,
|
||||
PFM_LOGO_DATA_URI,
|
||||
} from '../../lib/markdownUtils';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
interface ComposeMessageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialRecipient?: SelectedUser;
|
||||
initialSubject?: string;
|
||||
initialMessage?: string;
|
||||
initialPlatform?: NotificationPlatform;
|
||||
draftToEdit?: DraftMessage | null;
|
||||
}
|
||||
|
||||
export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialRecipient,
|
||||
initialSubject = '',
|
||||
initialMessage = '',
|
||||
initialPlatform = NotificationPlatform.InApp,
|
||||
draftToEdit,
|
||||
}) => {
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
|
||||
const tenantName =
|
||||
(claims?.tenant as string) ||
|
||||
user?.tenant ||
|
||||
'Department of Science, Innovation & Technology';
|
||||
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [platform, setPlatform] = useState<NotificationPlatform>(initialPlatform);
|
||||
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isCurrentUser = (candidate?: { id?: string; email?: string }) => {
|
||||
if (!candidate) return false;
|
||||
const cid = String(candidate.id || '').trim().toLowerCase();
|
||||
const cemail = String(candidate.email || '').trim().toLowerCase();
|
||||
if (currentUserId && cid === currentUserId) return true;
|
||||
if (currentUserEmail && cemail === currentUserEmail) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const toInitialRichHtml = (raw: string): string => {
|
||||
if (!raw || !raw.trim()) return '';
|
||||
if (isHtmlContent(raw)) return raw;
|
||||
return parseMarkdownToHtml(raw);
|
||||
};
|
||||
|
||||
// Initialize or populate from draft or props
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let initialHtml = '';
|
||||
if (draftToEdit) {
|
||||
const candidate = draftToEdit.recipientId
|
||||
? {
|
||||
id: draftToEdit.recipientId,
|
||||
email: draftToEdit.recipientEmail,
|
||||
name: draftToEdit.recipientName,
|
||||
}
|
||||
: undefined;
|
||||
setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
|
||||
setSubject(draftToEdit.subject || '');
|
||||
initialHtml = toInitialRichHtml(draftToEdit.message || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
|
||||
} else {
|
||||
setSelectedUsers(
|
||||
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
|
||||
);
|
||||
setSubject(initialSubject || '');
|
||||
initialHtml = toInitialRichHtml(initialMessage || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(initialPlatform);
|
||||
}
|
||||
setActiveTab('write');
|
||||
setError(null);
|
||||
setSuccessNotice(null);
|
||||
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = initialHtml;
|
||||
}
|
||||
}, 0);
|
||||
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
||||
|
||||
// Restore editor innerHTML when switching back from preview tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'write' && editorRef.current) {
|
||||
if (editorRef.current.innerHTML !== message) {
|
||||
editorRef.current.innerHTML = message;
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelectUser = (selected: SelectedUser) => {
|
||||
if (isCurrentUser(selected)) {
|
||||
setError('You cannot send a message to yourself.');
|
||||
return;
|
||||
}
|
||||
setSelectedUsers([selected]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleRemoveUser = () => {
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
|
||||
const syncEditorContent = () => {
|
||||
if (editorRef.current) {
|
||||
setMessage(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFormat = (command: string, value?: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
document.execCommand(command, false, value);
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
const insertHtmlBlock = (htmlSnippet: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
|
||||
if (!inserted) {
|
||||
editor.innerHTML += htmlSnippet;
|
||||
}
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
// Quick insertions for PFM branding and templates
|
||||
const insertBrandedCallout = () => {
|
||||
insertHtmlBlock(
|
||||
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertMilestoneTable = () => {
|
||||
insertHtmlBlock(
|
||||
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertPlatformLogo = () => {
|
||||
insertHtmlBlock(
|
||||
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const hasMeaningfulContent = (html: string) => {
|
||||
if (!html) return false;
|
||||
if (stripMarkdown(html).trim().length > 0) return true;
|
||||
return /<(img|table|hr)\b/i.test(html);
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please add a subject or message to save as draft');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstUser = selectedUsers[0];
|
||||
saveDraft({
|
||||
id: draftToEdit?.id,
|
||||
recipientId: firstUser?.id || '',
|
||||
recipientEmail: firstUser?.email || '',
|
||||
recipientName: firstUser?.name || '',
|
||||
subject: subject.trim() || 'Untitled Draft',
|
||||
message: currentHtml.trim(),
|
||||
platform,
|
||||
});
|
||||
|
||||
setSuccessNotice('Draft saved successfully in Drafts folder');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
|
||||
if (selectedUsers.length === 0) {
|
||||
setError('Please select a recipient from the tenant directory');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedUsers.length > 1) {
|
||||
setError('Only one recipient is allowed per message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentUser(selectedUsers[0])) {
|
||||
setError('You cannot send a message to yourself');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
setError('Please enter a subject');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please enter a message body');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const recipient = selectedUsers[0];
|
||||
|
||||
const finalMessage =
|
||||
platform === NotificationPlatform.Email
|
||||
? renderMarkdownToHtml(currentHtml.trim(), {
|
||||
applyBranding: true,
|
||||
emailFormat: true,
|
||||
subject: subject.trim(),
|
||||
tenantName,
|
||||
})
|
||||
: currentHtml.trim();
|
||||
|
||||
await sendMessage({
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
subject: subject.trim(),
|
||||
message: finalMessage,
|
||||
platform,
|
||||
draftId: draftToEdit?.id,
|
||||
});
|
||||
|
||||
setSuccessNotice(
|
||||
platform === NotificationPlatform.Email
|
||||
? 'Email notification dispatched!'
|
||||
: 'In-App message sent successfully!'
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 700);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to dispatch notification');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (draftToEdit?.id) {
|
||||
deleteDraft(draftToEdit.id);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Preview content based on chosen platform
|
||||
const livePreviewHtml =
|
||||
platform === NotificationPlatform.Email
|
||||
? renderMarkdownToHtml(message, {
|
||||
applyBranding: true,
|
||||
emailFormat: true,
|
||||
subject: subject || 'Notice Preview',
|
||||
tenantName,
|
||||
})
|
||||
: message;
|
||||
|
||||
const plainCharCount = stripMarkdown(message).length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-3xl bg-[#0d1322] border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/50 via-slate-900/60 to-slate-900/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandLogo size={28} showGlow animateOnHover={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-white tracking-tight">
|
||||
{draftToEdit
|
||||
? 'Edit Saved Draft'
|
||||
: initialSubject.startsWith('Re:')
|
||||
? 'Reply to Message'
|
||||
: 'Compose New Communication'}
|
||||
</h3>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
||||
Rich Text Composer
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Type and format your message using the toolbar for In-App or branded Email delivery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleSend} className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successNotice && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||
<span>{successNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform Selector (InApp vs Email) */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-200 block">
|
||||
Dispatch Platform Mode
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Select target transmission protocol for this notification
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-xl bg-slate-950 p-1 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.InApp)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.InApp
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
<span>In-App</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.Email)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.Email
|
||||
? 'bg-emerald-600 text-white shadow-md shadow-emerald-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span>Email</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Description Indicator */}
|
||||
<div className="px-1 text-[11px]">
|
||||
{platform === NotificationPlatform.InApp ? (
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient Picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
To (Recipient) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<UserSearchPicker
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onRemoveUser={handleRemoveUser}
|
||||
multiple={false}
|
||||
placeholder="Search tenant users by name, email, or functional role..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Subject Line <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="e.g. Funding Award Contract Review & Milestone Verification Notice"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rich Text Editor / Branded Preview Tabs */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Message Content <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
|
||||
{/* Compose vs Email Preview Tabs */}
|
||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('write')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'write'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
syncEditorContent();
|
||||
setActiveTab('preview');
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Preview Delivery</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||
{/* WYSIWYG Formatting Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('bold')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('italic')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('underline')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Heading"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Quote Block"
|
||||
>
|
||||
<Quote className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertMilestoneTable}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Deliverable Table"
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertHorizontalRule')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Divider Line"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
{/* Platform Brand Insertions */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertBrandedCallout}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
||||
title="Insert official PFM callout block"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 text-blue-400" />
|
||||
<span>PFM Notice</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertPlatformLogo}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
||||
title="Embed official PFM logo"
|
||||
>
|
||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||
<span>Embed Logo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rich Text WYSIWYG Surface */}
|
||||
<div className="relative">
|
||||
{!hasMeaningfulContent(message) && (
|
||||
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
|
||||
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={syncEditorContent}
|
||||
onBlur={syncEditorContent}
|
||||
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Fully-Rendered Delivery Preview */
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
||||
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
Subject: {subject || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMeaningfulContent(message) ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||
Type your message in the Compose tab to preview how it will appear upon delivery.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Metadata */}
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1 pt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Tenant Partition:</span>
|
||||
<strong className="text-slate-300">{tenantName}</strong>
|
||||
</span>
|
||||
<span className="font-mono">{plainCharCount} characters</span>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 text-xs font-medium transition cursor-pointer"
|
||||
title="Discard draft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDraft}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition cursor-pointer"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Save Draft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-medium border border-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{platform === NotificationPlatform.Email
|
||||
? 'Dispatch Email'
|
||||
: 'Send In-App Message'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
subject?: string;
|
||||
showBrandingHeader?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
content,
|
||||
showBrandingHeader = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No content</p>';
|
||||
if (isHtml) return content;
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isHtml]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(stripMarkdown(content) || content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/40 border border-slate-800/80 overflow-hidden ${className}`}>
|
||||
{/* Top Controls Bar */}
|
||||
<div className="px-4 py-2 border-b border-slate-800 bg-[#0d1322]/80 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{showBrandingHeader && (
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
||||
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-[11px] tracking-tight">PFM Communications</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-blue-400 font-mono">
|
||||
In-App Message
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
||||
title="Copy message text to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Rendering */}
|
||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||
<div
|
||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Branded Footer Stamp */}
|
||||
{showBrandingHeader && (
|
||||
<div className="px-5 py-2.5 border-t border-slate-800/80 bg-[#0a0f1b] flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Verified PFM Secure Platform Formatting</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
User as UserIcon,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
Briefcase,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { getUsers } from '../../services/api';
|
||||
import { User, ActivityStatus } from '../../types';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
export interface SelectedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserSearchPickerProps {
|
||||
selectedUsers: SelectedUser[];
|
||||
onSelectUser: (user: SelectedUser) => void;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onRemoveUser,
|
||||
placeholder = 'Search by name, email, or role...',
|
||||
}) => {
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Query tenant users
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
});
|
||||
|
||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||
const activeUsers = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (u.status !== ActivityStatus.Active || !u.email) return false;
|
||||
const uid = String(u.id || '').trim().toLowerCase();
|
||||
const uemail = u.email.trim().toLowerCase();
|
||||
if (currentUserId && uid === currentUserId) return false;
|
||||
if (currentUserEmail && uemail === currentUserEmail) return false;
|
||||
return true;
|
||||
});
|
||||
}, [users, currentUserId, currentUserEmail]);
|
||||
|
||||
// Organize users into intuitive functional groups
|
||||
const groupedUsers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
const filtered = activeUsers.filter(u => {
|
||||
if (!term) return true;
|
||||
const email = (u.email || '').toLowerCase();
|
||||
const role = String(u.role ?? '').toLowerCase();
|
||||
return email.includes(term) || role.includes(term);
|
||||
});
|
||||
|
||||
const adminUsers: User[] = [];
|
||||
const pmUsers: User[] = [];
|
||||
const financeUsers: User[] = [];
|
||||
const meUsers: User[] = [];
|
||||
const otherUsers: User[] = [];
|
||||
|
||||
filtered.forEach(u => {
|
||||
const r = String(u.role ?? '').toLowerCase();
|
||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||
adminUsers.push(u);
|
||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||
pmUsers.push(u);
|
||||
} else if (r.includes('finance') || r.includes('disbursement') || r.includes('accountant') || r.includes('budget')) {
|
||||
financeUsers.push(u);
|
||||
} else if (r.includes('m&e') || r.includes('monitoring') || r.includes('eval') || r.includes('risk') || r.includes('compliance')) {
|
||||
meUsers.push(u);
|
||||
} else {
|
||||
otherUsers.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: UserGroup[] = [];
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'admin',
|
||||
label: 'Leadership & Administration',
|
||||
icon: Shield,
|
||||
color: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
users: adminUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (pmUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'pm',
|
||||
label: 'Programme & Project Management',
|
||||
icon: Briefcase,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
users: pmUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (financeUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'finance',
|
||||
label: 'Finance & Disbursements',
|
||||
icon: Coins,
|
||||
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
users: financeUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (meUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'me',
|
||||
label: 'M&E, Compliance & Risks',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
users: meUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'team',
|
||||
label: 'Tenant Staff & Team Members',
|
||||
icon: Users,
|
||||
color: 'text-slate-400 bg-slate-500/10 border-slate-500/20',
|
||||
users: otherUsers,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [activeUsers, searchTerm]);
|
||||
|
||||
const toggleGroupCollapse = (groupId: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isUserSelected = (userId: string) => {
|
||||
return selectedUsers.some(u => u.id === userId);
|
||||
};
|
||||
|
||||
const handleUserClick = (u: User) => {
|
||||
if (!u.email) return;
|
||||
|
||||
if (isUserSelected(u.id)) {
|
||||
onRemoveUser(u.id);
|
||||
} else {
|
||||
const displayName = u.email.split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Strictly single-recipient selection
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: String(u.role ?? 'User'),
|
||||
});
|
||||
setSearchTerm('');
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const singleSelectedUser = selectedUsers[0];
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Single Selected Recipient or Search Input Box */}
|
||||
{singleSelectedUser ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
|
||||
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-semibold text-white truncate">
|
||||
{singleSelectedUser.name || singleSelectedUser.email}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-mono truncate">
|
||||
<{singleSelectedUser.email}>
|
||||
</span>
|
||||
{singleSelectedUser.role && (
|
||||
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
|
||||
{singleSelectedUser.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUser(singleSelectedUser.id)}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
|
||||
title="Remove recipient to select another user"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span>Change</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isDropdownOpen) setIsDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
|
||||
{isDropdownOpen && !singleSelectedUser && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading tenant users directory...</span>
|
||||
</div>
|
||||
) : groupedUsers.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
No matching users found for "{searchTerm}".
|
||||
</div>
|
||||
) : (
|
||||
groupedUsers.map(group => {
|
||||
const GroupIcon = group.icon;
|
||||
const isCollapsed = collapsedGroups[group.id];
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapse(group.id)}
|
||||
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/60 text-slate-300 text-[11px] font-semibold transition cursor-pointer border border-slate-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`p-1 rounded-md border ${group.color}`}>
|
||||
<GroupIcon className="w-3 h-3" />
|
||||
</span>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded-full border border-slate-700 font-mono">
|
||||
{group.users.length}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Users inside group */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 gap-1 pl-1 pr-1 pt-0.5">
|
||||
{group.users.map(u => {
|
||||
const selected = isUserSelected(u.id);
|
||||
const displayName = (u.email || '').split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => handleUserClick(u)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition cursor-pointer border ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-white'
|
||||
: 'bg-slate-900/40 hover:bg-slate-800/70 border-slate-800/40 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className={`w-7 h-7 shrink-0 rounded-full flex items-center justify-center font-bold text-[10px] border ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-400 text-white'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{formattedName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs truncate text-white">{formattedName}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono truncate">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{u.role && (
|
||||
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/20 px-1.5 py-0.5 rounded truncate max-w-[120px]">
|
||||
{u.role}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`w-4 h-4 rounded-md border flex items-center justify-center ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-500 text-white'
|
||||
: 'border-slate-700 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Close button at bottom */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
className="text-[11px] text-slate-400 hover:text-white px-3 py-1 rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Done Selecting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
Flag,
|
||||
X,
|
||||
RotateCcw,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -23,15 +27,17 @@ import {
|
||||
createActivity,
|
||||
updateActivity,
|
||||
getMilestones,
|
||||
getProjectIndicators,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import { Project, Activity, Milestone } from '../../types';
|
||||
import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
|
||||
import { formatDate, formatZAR } from '../../lib/formatters';
|
||||
|
||||
const activitySchema = z.object({
|
||||
title: z.string().min(3, 'Activity title required'),
|
||||
description: z.string().min(5, 'Description required'),
|
||||
category: z.string().min(2, 'Category required'),
|
||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
||||
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
|
||||
milestoneId: z.string().optional().nullable(),
|
||||
performedAt: z.string().optional().nullable(),
|
||||
});
|
||||
@@ -42,12 +48,14 @@ interface ProjectActivitiesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
|
||||
}
|
||||
|
||||
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onOpenIndicatorsForActivity,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
@@ -73,6 +81,45 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: indicators = [] } = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Approved project budget calculations
|
||||
const approvedBudgetTotal = useMemo(() => {
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
return projectBudgets
|
||||
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
}, [allBudgets, projectId]);
|
||||
|
||||
const totalAllocated = useMemo(
|
||||
() => activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0),
|
||||
[activities]
|
||||
);
|
||||
|
||||
const maxAllowedForForm = useMemo(() => {
|
||||
if (approvedBudgetTotal <= 0) return Infinity;
|
||||
const baseRemaining = approvedBudgetTotal - totalAllocated;
|
||||
if (editingActivity) {
|
||||
return baseRemaining + (Number(editingActivity.amount) || 0);
|
||||
}
|
||||
return Math.max(0, baseRemaining);
|
||||
}, [approvedBudgetTotal, totalAllocated, editingActivity]);
|
||||
|
||||
const remainingBudget = useMemo(() => {
|
||||
if (approvedBudgetTotal <= 0) return 0;
|
||||
return Math.max(0, approvedBudgetTotal - totalAllocated);
|
||||
}, [approvedBudgetTotal, totalAllocated]);
|
||||
|
||||
const form = useForm<ActivityFormValues>({
|
||||
resolver: zodResolver(activitySchema),
|
||||
defaultValues: {
|
||||
@@ -114,6 +161,14 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: ActivityFormValues): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
|
||||
const requestedAmount = Number(data.amount);
|
||||
if (approvedBudgetTotal > 0 && requestedAmount > maxAllowedForForm) {
|
||||
throw new Error(
|
||||
`Activity amount (${formatZAR(requestedAmount)}) exceeds the available remaining project budget (${formatZAR(maxAllowedForForm)}).`
|
||||
);
|
||||
}
|
||||
|
||||
const parsedMilestoneId =
|
||||
data.milestoneId && String(data.milestoneId).trim() !== ''
|
||||
? Number(data.milestoneId)
|
||||
@@ -127,7 +182,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: Number(data.amount),
|
||||
amount: requestedAmount,
|
||||
milestoneId: parsedMilestoneId,
|
||||
performedAt: performedAtIso,
|
||||
});
|
||||
@@ -137,7 +192,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: Number(data.amount),
|
||||
amount: requestedAmount,
|
||||
milestoneId: parsedMilestoneId,
|
||||
performedAt: performedAtIso,
|
||||
});
|
||||
@@ -145,6 +200,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success(
|
||||
editingActivity ? 'Activity Updated' : 'Activity Created',
|
||||
@@ -163,8 +219,6 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -175,23 +229,52 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
}}
|
||||
title={`Work Breakdown Activities: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`}
|
||||
helpText="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations."
|
||||
helpText="Under each milestone, operational activities are scheduled with budgeted amounts. The sum of all activity budgets across the project cannot exceed the approved project budget."
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Summary Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Total Allocated Work Budget
|
||||
Total Allocated Activities
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span>
|
||||
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
|
||||
<span className="text-[11px] text-slate-400">({activities.length} tasks)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Approved Project Budget
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-blue-400" />
|
||||
Remaining Budget Ceiling
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className={`text-xl font-bold font-mono ${remainingBudget <= 0 && approvedBudgetTotal > 0 ? 'text-rose-400' : 'text-blue-400'}`}>
|
||||
{formatZAR(remainingBudget)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header */}
|
||||
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<span className="text-xs text-slate-400">
|
||||
Scheduled Tasks: <strong className="text-white">{activities.length}</strong>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
@@ -226,6 +309,13 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approvedBudgetTotal > 0 && (
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 border border-blue-500/25 text-xs flex items-center justify-between text-blue-300">
|
||||
<span>Maximum activity budget allowance:</span>
|
||||
<strong className="font-mono">{formatZAR(maxAllowedForForm)}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
|
||||
@@ -262,6 +352,7 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Amount (ZAR) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register('amount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
@@ -350,7 +441,9 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Briefcase className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p>
|
||||
<p className="text-[11px] text-slate-500">Deconstruct the project into operational tasks with allocated budgets.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Deconstruct the project milestones into operational tasks with allocated budgets.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -360,17 +453,24 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
{activities.map((a) => {
|
||||
const linkedMilestone = a.milestoneId
|
||||
? milestones.find((m) => String(m.id) === String(a.milestoneId))
|
||||
: null;
|
||||
|
||||
const actIndicators = indicators.filter((i) => String(i.activityId) === String(a.id));
|
||||
const actActualCost = actIndicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
|
||||
const isOverrun = actActualCost > Number(a.amount) && a.amount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-start justify-between gap-3"
|
||||
className={`p-3.5 rounded-xl border bg-[#0d121f] transition space-y-2.5 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{a.title}</span>
|
||||
@@ -394,7 +494,16 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
||||
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span>
|
||||
<div className="text-right mr-1">
|
||||
<span className="block font-mono text-xs font-bold text-cyan-400">
|
||||
{formatZAR(a.amount)}
|
||||
</span>
|
||||
{actActualCost > 0 && (
|
||||
<span className={`block font-mono text-[10px] ${isOverrun ? 'text-rose-400 font-bold' : 'text-slate-400'}`}>
|
||||
Actual: {formatZAR(actActualCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
|
||||
@@ -415,6 +524,50 @@ export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Delivery Indicators Section */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-slate-400 flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-indigo-400" />
|
||||
Delivery Specifications:
|
||||
</span>
|
||||
{actIndicators.length === 0 ? (
|
||||
<span className="text-[11px] text-slate-500 italic">No indicators defined for this activity yet.</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{actIndicators.map((ind) => (
|
||||
<span
|
||||
key={ind.id}
|
||||
className="px-2 py-0.5 rounded bg-slate-900 border border-slate-700/70 text-[10px] text-slate-300 font-mono"
|
||||
>
|
||||
{ind.name}: {ind.actualAmount}/{ind.targetAmount}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOverrun && (
|
||||
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
||||
⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onOpenIndicatorsForActivity && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenIndicatorsForActivity(a.id)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
|
||||
>
|
||||
<span>Manage Indicators ({actIndicators.length})</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Target,
|
||||
Coins,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { ComplianceStatusBadge } from '../ui/StatusBadge';
|
||||
import {
|
||||
Project,
|
||||
Programme,
|
||||
Outcome,
|
||||
ComplianceStatus,
|
||||
ApprovalStatus,
|
||||
} from '../../types';
|
||||
import {
|
||||
getProjectComplianceItems,
|
||||
getProjectBeneficiaries,
|
||||
getBeneficiaryOutcomes,
|
||||
getProjectRisks,
|
||||
getMilestones,
|
||||
getActivities,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getRiskScoreColor,
|
||||
} from '../../lib/formatters';
|
||||
|
||||
interface ProjectComplianceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
programme: Programme | null;
|
||||
}
|
||||
|
||||
export const ProjectComplianceModal: React.FC<ProjectComplianceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
programme,
|
||||
}) => {
|
||||
const projectId = project?.id;
|
||||
|
||||
// Queries
|
||||
const { data: complianceItems = [], isLoading: loadingCompliance } = useQuery({
|
||||
queryKey: ['complianceItems', projectId],
|
||||
queryFn: () => (projectId ? getProjectComplianceItems(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: projectBeneficiaries = [], isLoading: loadingBeneficiaries } = useQuery({
|
||||
queryKey: ['projectBeneficiaries', projectId],
|
||||
queryFn: () => (projectId ? getProjectBeneficiaries(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
||||
queryKey: ['risks', projectId],
|
||||
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: milestones = [] } = useQuery({
|
||||
queryKey: ['milestones', projectId],
|
||||
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: activities = [] } = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
// Beneficiary outcomes query
|
||||
const { data: outcomes = [], isLoading: loadingOutcomes } = useQuery({
|
||||
queryKey: ['beneficiaryOutcomes', projectBeneficiaries.map((b) => b.id)],
|
||||
queryFn: async (): Promise<Outcome[]> => {
|
||||
if (projectBeneficiaries.length === 0) return [];
|
||||
const results = await Promise.all(
|
||||
projectBeneficiaries.map(async (b) => {
|
||||
try {
|
||||
return await getBeneficiaryOutcomes(b.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
return results.flat();
|
||||
},
|
||||
enabled: isOpen && projectBeneficiaries.length > 0,
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
// Compliance calculations
|
||||
const totalCompCount = complianceItems.length;
|
||||
const metCompCount = complianceItems.filter(
|
||||
(c) => Number(c.status) === ComplianceStatus.Met || Number(c.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetCompCount = totalCompCount - metCompCount;
|
||||
|
||||
// Outcomes calculations
|
||||
const totalOutcomesCount = outcomes.length;
|
||||
const metOutcomesCount = outcomes.filter(
|
||||
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetOutcomesCount = totalOutcomesCount - metOutcomesCount;
|
||||
|
||||
// Overall compliance verdict (Invariant: 100% met required for approval)
|
||||
const is100PercentCompliant =
|
||||
unmetCompCount === 0 && unmetOutcomesCount === 0;
|
||||
|
||||
// Financial calculations
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
const approvedBudgetTotal = projectBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const financialVariance = approvedBudgetTotal - totalActivityBudget;
|
||||
const isWithinBudget = financialVariance >= 0;
|
||||
|
||||
// Milestone Progress
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Risk & Compliance Status: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • ${programme?.name || 'Programme'} • Statutory Governance Audit`}
|
||||
helpText="Comprehensive audit of project compliance items, beneficiary outcomes fulfillment, ISO 31000 risk register, and statutory approval eligibility."
|
||||
maxWidth="5xl"
|
||||
>
|
||||
<div className="space-y-6 text-slate-200">
|
||||
{/* Top Verdict Banner */}
|
||||
<div
|
||||
className={`p-4 sm:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-950/30 border-emerald-500/40'
|
||||
: 'bg-rose-950/30 border-rose-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`p-2.5 rounded-xl border mt-0.5 ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-rose-500/20 text-rose-400 border-rose-500/30'
|
||||
}`}
|
||||
>
|
||||
{is100PercentCompliant ? (
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
) : (
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
{is100PercentCompliant
|
||||
? '100% Fully Compliant — Approved for Project Governance'
|
||||
: 'Action Required: Project is Noncompliant'}
|
||||
</h4>
|
||||
<span
|
||||
className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border ${
|
||||
is100PercentCompliant
|
||||
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
|
||||
: 'bg-rose-500/20 text-rose-300 border-rose-500/40 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
{is100PercentCompliant ? 'Eligible for Approval' : 'Approvals Blocked'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-1">
|
||||
{is100PercentCompliant
|
||||
? 'All statutory compliance requirements and beneficiary impact outcomes have met or exceeded threshold requirements.'
|
||||
: `This project has ${unmetCompCount} unmet compliance item(s) and ${unmetOutcomesCount} unmet beneficiary outcome(s). Per governance policy, projects cannot be approved until all compliance items and outcomes are 100% met.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-slate-400 block uppercase font-semibold">Budget At Risk</span>
|
||||
<span className={`text-base font-bold font-mono ${is100PercentCompliant ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{is100PercentCompliant ? 'R 0.00' : formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4 Summary Metric Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<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">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Compliance Requirements
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">
|
||||
{metCompCount}/{totalCompCount}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-semibold">
|
||||
({totalCompCount > 0 ? Math.round((metCompCount / totalCompCount) * 100) : 100}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{unmetCompCount === 0 ? 'All items satisfied' : `${unmetCompCount} pending fulfillment`}
|
||||
</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">
|
||||
<Target className="w-3.5 h-3.5 text-purple-400" />
|
||||
Beneficiary Outcomes
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-purple-400">
|
||||
{metOutcomesCount}/{totalOutcomesCount}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-semibold">
|
||||
({totalOutcomesCount > 0 ? Math.round((metOutcomesCount / totalOutcomesCount) * 100) : 100}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Across {projectBeneficiaries.length} beneficiary group{projectBeneficiaries.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Financial Variance
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span
|
||||
className={`text-xl font-bold font-mono ${
|
||||
isWithinBudget ? 'text-emerald-400' : 'text-rose-400'
|
||||
}`}
|
||||
>
|
||||
{isWithinBudget ? `+${formatZAR(financialVariance)}` : `-${formatZAR(Math.abs(financialVariance))}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</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">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
Active Project Risks
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-amber-400">{risks.length}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
({risks.filter((r) => (Number(r.likelihood) || 1) * (Number(r.impact) || 1) >= 12).length} High)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-cyan-400 inline" /> Milestones: {milestoneProgress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Statutory Compliance Requirements */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Statutory Compliance Requirements ({complianceItems.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{metCompCount} of {complianceItems.length} Met / Exceeded
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingCompliance ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading compliance register...</div>
|
||||
) : complianceItems.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No specific statutory compliance requirements captured for this project yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
|
||||
<th className="py-2.5 px-4">Title</th>
|
||||
<th className="py-2.5 px-4">Requirements Specification</th>
|
||||
<th className="py-2.5 px-4">Linked Beneficiary</th>
|
||||
<th className="py-2.5 px-4">Status</th>
|
||||
<th className="py-2.5 px-4 text-right">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{complianceItems.map((ci) => {
|
||||
const linkedBen = projectBeneficiaries.find(
|
||||
(b) => String(b.id) === String(ci.beneficiaryId)
|
||||
);
|
||||
return (
|
||||
<tr key={ci.id} className="hover:bg-slate-900/30 transition">
|
||||
<td className="py-2.5 px-4 font-semibold text-white">{ci.title || 'Untitled Item'}</td>
|
||||
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={ci.requirements || ''}>
|
||||
{ci.requirements || 'N/A'}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-slate-300">
|
||||
{linkedBen ? (
|
||||
<span className="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 text-[11px]">
|
||||
{linkedBen.name || 'Beneficiary'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-500 italic">Project-Wide</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<ComplianceStatusBadge status={ci.status} />
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right text-slate-400 font-mono text-[11px]">
|
||||
{formatDate(ci.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Affected Beneficiaries & Mapped Outcomes */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Affected Beneficiaries & Mapped Impact Outcomes ({projectBeneficiaries.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Total Target: {projectBeneficiaries.reduce((sum, b) => sum + (Number(b.targetCount) || 0), 0)} Reached:{' '}
|
||||
{projectBeneficiaries.reduce((sum, b) => sum + (Number(b.reachedCount) || 0), 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingBeneficiaries || loadingOutcomes ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading beneficiaries & outcomes...</div>
|
||||
) : projectBeneficiaries.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No target beneficiaries mapped to this project yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{projectBeneficiaries.map((b) => {
|
||||
const benOutcomes = outcomes.filter((o) => String(o.beneficiaryId) === String(b.id));
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<span className="font-bold text-white text-xs mr-2">{b.name || 'Beneficiary Group'}</span>
|
||||
<span className="text-[11px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700/60">
|
||||
{b.category || 'General'}{b.location ? ` • ${b.location}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
<span className="text-slate-400">
|
||||
Target: <strong className="text-white">{b.targetCount ?? 0}</strong>
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-slate-400">
|
||||
Reached: <strong className="text-emerald-400">{b.reachedCount ?? 0}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapped Outcomes */}
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1.5">
|
||||
Mapped Expected Outcomes ({benOutcomes.length})
|
||||
</span>
|
||||
{benOutcomes.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-500 italic">No expected outcomes recorded for this group.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{benOutcomes.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="p-2.5 rounded-lg bg-slate-950/60 border border-slate-800/80 flex items-start justify-between gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-slate-200 text-xs block truncate">{o.name || 'Outcome'}</span>
|
||||
<span className="text-[10px] text-slate-400 line-clamp-1">{o.description || 'No description'}</span>
|
||||
</div>
|
||||
<ComplianceStatusBadge status={o.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Risk Profile (ISO 31000) */}
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Active Project Risks Register ({risks.length})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">ISO 31000 Risk Management</span>
|
||||
</div>
|
||||
|
||||
{loadingRisks ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
||||
) : risks.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No active risks logged on this project register.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800 text-slate-400 text-[11px] font-semibold">
|
||||
<th className="py-2.5 px-4">Risk Name</th>
|
||||
<th className="py-2.5 px-4">Category / Status</th>
|
||||
<th className="py-2.5 px-4 text-center">Score</th>
|
||||
<th className="py-2.5 px-4">Mitigation Strategy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{risks.map((r) => {
|
||||
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
|
||||
const scoreColor = getRiskScoreColor(r.likelihood, r.impact);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-slate-900/30 transition">
|
||||
<td className="py-2.5 px-4 font-semibold text-white">{r.name || r.title || 'Unnamed Risk'}</td>
|
||||
<td className="py-2.5 px-4 text-slate-300">{r.category || r.status || 'Operational'}</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${scoreColor.badge}`}
|
||||
>
|
||||
{scoreColor.level} ({score})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={r.mitigationAction || r.mitigation || r.description || ''}>
|
||||
{r.mitigationAction || r.mitigation || r.description || 'No mitigation recorded'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-end pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 transition"
|
||||
>
|
||||
Close Audit Inspector
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||
import {
|
||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', projectId],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
});
|
||||
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||
Project Value: {formatZAR(projectValue)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`text-xs font-mono font-semibold shrink-0 ${
|
||||
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||
}`}
|
||||
title="Approved Budget minus Net Project Value"
|
||||
>
|
||||
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,9 +199,14 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
{sBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono">
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
Due: {formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className="text-blue-400 font-bold">
|
||||
{formatZAR(activities.filter(a => String(a.milestoneId) === String(m.id)).reduce((s, a) => s + (Number(a.amount) || 0), 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
TrendingUp,
|
||||
X,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Edit3,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
Filter,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -18,16 +22,18 @@ import { useToast } from '../ui/Toast';
|
||||
import {
|
||||
getProjectIndicators,
|
||||
createIndicator,
|
||||
recordIndicatorActual,
|
||||
getActivities,
|
||||
} from '../../services/api';
|
||||
import { Project, Indicator, UnitOfMeasure } from '../../types';
|
||||
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters';
|
||||
import { Project, Indicator, UnitOfMeasure, Activity } from '../../types';
|
||||
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters';
|
||||
|
||||
const indicatorSchema = z.object({
|
||||
name: z.string().min(5, 'Indicator definition required (min 5 chars)'),
|
||||
name: z.string().min(4, 'Indicator delivery specification required (min 4 chars)'),
|
||||
activityId: z.string().optional().nullable(),
|
||||
unitOfMeasure: z.coerce.number().min(1),
|
||||
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
|
||||
targetAmount: z.coerce.number().min(1, 'Target must be > 0'),
|
||||
targetCost: z.coerce.number().min(0, 'Target cost must be >= 0').optional(),
|
||||
});
|
||||
|
||||
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
|
||||
@@ -36,25 +42,26 @@ interface ProjectIndicatorsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
initialActivityId?: string | number | null;
|
||||
}
|
||||
|
||||
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
initialActivityId = null,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null);
|
||||
const [actualInput, setActualInput] = useState<number>(0);
|
||||
const [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
|
||||
|
||||
const projectId = project?.id;
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading,
|
||||
isLoading: isLoadingIndicators,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
@@ -62,31 +69,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: isLoadingActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const form = useForm<IndicatorFormValues>({
|
||||
resolver: zodResolver(indicatorSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
activityId: initialActivityId ? String(initialActivityId) : '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
baselineAmount: 0,
|
||||
targetAmount: 100,
|
||||
targetCost: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: IndicatorFormValues) => {
|
||||
if (!projectId) return;
|
||||
const parsedActivityId = data.activityId && String(data.activityId).trim() !== ''
|
||||
? data.activityId
|
||||
: null;
|
||||
|
||||
return createIndicator({
|
||||
projectId: projectId,
|
||||
activityId: parsedActivityId,
|
||||
name: data.name,
|
||||
unitOfMeasure: Number(data.unitOfMeasure),
|
||||
baselineAmount: Number(data.baselineAmount),
|
||||
targetAmount: Number(data.targetAmount),
|
||||
targetCost: Number(data.targetCost) || 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||||
success('Indicator Created', 'M&E Key Performance Indicator added to project');
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
||||
success('Delivery Indicator Created', 'Indicator specification linked to work activity');
|
||||
setIsFormOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
@@ -95,20 +120,22 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const actualMutation = useMutation({
|
||||
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => {
|
||||
return recordIndicatorActual(indicatorId, actualAmount);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||||
success('Actual Value Logged', 'M&E progress measurement updated successfully');
|
||||
setRecordingActualForId(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to record actual value', err);
|
||||
},
|
||||
});
|
||||
// Filter indicators by activity
|
||||
const filteredIndicators = useMemo(() => {
|
||||
if (selectedActivityFilter === 'all') return indicators;
|
||||
if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId);
|
||||
return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter));
|
||||
}, [indicators, selectedActivityFilter]);
|
||||
|
||||
// Aggregate totals
|
||||
const totalTargetCost = useMemo(
|
||||
() => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0),
|
||||
[indicators]
|
||||
);
|
||||
const totalActualCost = useMemo(
|
||||
() => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0),
|
||||
[indicators]
|
||||
);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
@@ -117,34 +144,86 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
setIsFormOpen(false);
|
||||
setRecordingActualForId(null);
|
||||
onClose();
|
||||
}}
|
||||
title={`M&E Performance Indicators: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Results & Impact Framework`}
|
||||
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)."
|
||||
title={`M&E Delivery Indicators: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`}
|
||||
helpText="Each indicator specifies an expected delivery output linked to an operational activity. Quantities and material costs are captured as actuals by the partner in the Partner Portal."
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Summary Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Target className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Monitoring & Evaluation Targets
|
||||
Delivery Indicators
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white">{indicators.length}</span>
|
||||
<span className="text-xs text-slate-400">Tracked performance metrics</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
|
||||
<span className="text-[11px] text-slate-400">specifications</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Target Delivery Cost
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalTargetCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Partner Actuals Incurred
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className={`text-xl font-bold font-mono ${totalActualCost > totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{formatZAR(totalActualCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header & Activity Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">Filter by Activity:</span>
|
||||
<select
|
||||
value={selectedActivityFilter}
|
||||
onChange={(e) => setSelectedActivityFilter(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-slate-700/80 rounded-lg focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white max-w-xs truncate"
|
||||
>
|
||||
<option value="all">All Activities ({indicators.length})</option>
|
||||
{activities.map((act) => {
|
||||
const count = indicators.filter((i) => String(i.activityId) === String(act.id)).length;
|
||||
return (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} ({count} indicators)
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<option value="unlinked">Unlinked General Indicators</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
form.reset({
|
||||
name: '',
|
||||
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
baselineAmount: 0,
|
||||
targetAmount: 100,
|
||||
targetCost: 0,
|
||||
});
|
||||
setIsFormOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer"
|
||||
@@ -156,13 +235,13 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Indicator Form */}
|
||||
{/* Indicator Create Form */}
|
||||
{isFormOpen && (
|
||||
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
|
||||
<span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
|
||||
<Target className="w-3.5 h-3.5" />
|
||||
Define New Key Performance Indicator (KPI)
|
||||
Define Activity Delivery Indicator & Evidence Expectation
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -175,19 +254,41 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Indicator Name / Delivery Output Specification *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...form.register('name')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white bg-[#090d16]"
|
||||
placeholder="e.g. Number of Smallholder Farmers Trained in Precision Irrigation"
|
||||
placeholder="e.g. Number of solar microgrids commissioned and tested"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Parent Activity (Required Delivery Link) *
|
||||
</label>
|
||||
<select
|
||||
{...form.register('activityId')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select Parent Activity --</option>
|
||||
{activities.map((act) => (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} (Budget: {formatZAR(act.amount)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Linking to an activity establishes the causal delivery chain from planned task to substantiated output.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
|
||||
<select
|
||||
@@ -203,32 +304,47 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Expected Delivery Cost (Target Cost ZAR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register('targetCost')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Estimated monetary cost for delivering this indicator.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Metric *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...form.register('baselineAmount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Amount *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Metric *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...form.register('targetAmount')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
|
||||
/>
|
||||
{form.formState.errors.targetAmount && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.targetAmount.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
className="px-3 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -237,17 +353,27 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer"
|
||||
>
|
||||
{createMutation.isPending ? 'Saving...' : 'Add Indicator'}
|
||||
{createMutation.isPending ? 'Saving...' : 'Define Indicator'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notice Card Explaining Partner Action */}
|
||||
<div className="bg-slate-900/60 p-3 rounded-xl border border-slate-800 flex items-start gap-2.5 text-slate-400 text-[11px] leading-relaxed">
|
||||
<Info className="w-4 h-4 text-cyan-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-slate-200">Maker-Checker Operational Notice:</strong> ConsoleUI enables Project Managers to establish output specifications and expected delivery costs. Actual item quantities, material expenditures, and verification evidence documents are recorded by the Grantee/Partner via the <strong className="text-cyan-400 font-mono">PartnerUI Portal</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators List */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
|
||||
<span>Performance Target Registry ({indicators.length})</span>
|
||||
<span>
|
||||
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
@@ -257,49 +383,77 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{isLoadingIndicators || isLoadingActivities ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
|
||||
Loading indicators...
|
||||
</div>
|
||||
) : indicators.length === 0 ? (
|
||||
) : filteredIndicators.length === 0 ? (
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Target className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No M&E indicators defined yet</p>
|
||||
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p>
|
||||
<p className="text-xs font-semibold text-slate-300">No indicators matching selection</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Define delivery output specifications and link them to operational activities.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add first indicator
|
||||
<Plus className="w-3.5 h-3.5" /> Define first indicator
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
|
||||
{indicators.map((ind) => {
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
{filteredIndicators.map((ind) => {
|
||||
const target = Number(ind.targetAmount) || 1;
|
||||
const actual = Number(ind.actualAmount) || 0;
|
||||
const baseline = Number(ind.baselineAmount) || 0;
|
||||
const targetCost = Number(ind.targetCost) || 0;
|
||||
const actualCost = Number(ind.actualCost) || 0;
|
||||
const pct = Math.round((actual / target) * 100);
|
||||
const isRecordingThis = recordingActualForId === ind.id;
|
||||
|
||||
const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId));
|
||||
const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ind.id}
|
||||
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-3"
|
||||
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{ind.name}</span>
|
||||
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
|
||||
{getUnitOfMeasureLabel(ind.unitOfMeasure)}
|
||||
</span>
|
||||
{parentActivity ? (
|
||||
<span className="bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 px-2 py-0.5 rounded-md text-[10px] flex items-center gap-1">
|
||||
<Briefcase className="w-2.5 h-2.5" />
|
||||
{parentActivity.title}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md text-[10px]">
|
||||
General Project Indicator
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
Indicator ID #{ind.id}
|
||||
{ind.activityId && ` • Activity #${ind.activityId}`}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOverrun && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Cost Overrun
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
|
||||
pct >= 100
|
||||
@@ -309,34 +463,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
{pct}% Achieved
|
||||
{pct}% Delivered
|
||||
</span>
|
||||
|
||||
{!isRecordingThis ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRecordingActualForId(ind.id);
|
||||
setActualInput(actual);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/25 px-2.5 py-1 rounded-lg transition cursor-pointer"
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded-md"
|
||||
title="Actuals are recorded by the partner in PartnerUI"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<ShieldCheck className="w-3 h-3 text-cyan-400" />
|
||||
Partner Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{/* Deliverable Units Progress Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||||
<div
|
||||
@@ -351,40 +491,45 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-slate-400">
|
||||
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span>
|
||||
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span>
|
||||
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span>
|
||||
<span>
|
||||
Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Partner Actuals Reported:{' '}
|
||||
<strong className="text-white font-mono font-bold">{actual}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Target: <strong className="text-indigo-400 font-mono">{target}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Record Actual Form */}
|
||||
{isRecordingThis && (
|
||||
<div className="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in">
|
||||
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={actualInput}
|
||||
onChange={(e) => setActualInput(Number(e.target.value))}
|
||||
className="w-24 px-2 py-1 text-xs border border-slate-700 rounded-lg text-white font-mono bg-[#090d16] focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actualMutation.isPending}
|
||||
onClick={() =>
|
||||
actualMutation.mutate({
|
||||
indicatorId: ind.id,
|
||||
actualAmount: actualInput,
|
||||
})
|
||||
}
|
||||
className="px-3 py-1 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg shadow-sm transition cursor-pointer"
|
||||
>
|
||||
{actualMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/* Financial Cost Specification & Actuals Row */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-4 text-slate-400 text-[11px]">
|
||||
<span>
|
||||
Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Actual Material Cost:{' '}
|
||||
<strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
|
||||
{formatZAR(actualCost)}
|
||||
</strong>
|
||||
</span>
|
||||
{targetCost > 0 && (
|
||||
<span className="text-slate-500">
|
||||
Variance: {formatZAR(targetCost - actualCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOverrun && (
|
||||
<div className="text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
Cost exceeds delivery allocation; subject to project variation approval (M15).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
X,
|
||||
RotateCcw,
|
||||
Lock,
|
||||
Coins,
|
||||
Briefcase,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -24,11 +28,15 @@ import {
|
||||
getMilestones,
|
||||
createMilestone,
|
||||
updateMilestone,
|
||||
getActivities,
|
||||
getProjectIndicators,
|
||||
getBudgets,
|
||||
} from '../../services/api';
|
||||
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
|
||||
import { Project, Milestone, Priority, ApprovalStatus, Activity, Indicator } from '../../types';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
PRIORITY_OPTIONS,
|
||||
@@ -66,7 +74,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
const {
|
||||
data: milestones = [],
|
||||
isLoading,
|
||||
isLoading: isLoadingMilestones,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['milestones', projectId],
|
||||
@@ -74,6 +82,54 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: isLoadingActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading: isLoadingIndicators,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Calculate project approved budget
|
||||
const approvedBudgetTotal = useMemo(() => {
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||
return projectBudgets
|
||||
.filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
}, [allBudgets, projectId]);
|
||||
|
||||
// Total project value is the sum total from all milestones (sum of their activities)
|
||||
const totalProjectMilestonesValue = useMemo(() => {
|
||||
return milestones.reduce((sum, m) => {
|
||||
const mActs = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
return sum + mActs.reduce((s, a) => s + (Number(a.amount) || 0), 0);
|
||||
}, 0);
|
||||
}, [milestones, activities]);
|
||||
|
||||
// Total project actuals reported by partner from indicators
|
||||
const totalProjectActuals = useMemo(() => {
|
||||
return indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
|
||||
}, [indicators]);
|
||||
|
||||
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
|
||||
|
||||
const form = useForm<MilestoneFormValues>({
|
||||
resolver: zodResolver(milestoneSchema),
|
||||
defaultValues: {
|
||||
@@ -147,9 +203,6 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -160,22 +213,89 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
}}
|
||||
title={`Milestones & Deliverables: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`}
|
||||
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
|
||||
maxWidth="xl"
|
||||
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="4xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Progress & Quick Stats Card */}
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
|
||||
Calculated Completion Score
|
||||
{/* KPI Cards: Project Value Roll-Up & Performance */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
Total Project Value
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white">{progressPct}%</span>
|
||||
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-blue-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectMilestonesValue)}
|
||||
>
|
||||
{formatZAR(totalProjectMilestonesValue)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Sum of all milestones</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
Approved Budget
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-emerald-400 truncate tracking-tight"
|
||||
title={formatZAR(approvedBudgetTotal)}
|
||||
>
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Allocated budget ceiling</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
Partner Actuals
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-cyan-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectActuals)}
|
||||
>
|
||||
{formatZAR(totalProjectActuals)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Incurred material costs</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
Milestone Progress
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-1.5">
|
||||
<div className="text-lg sm:text-xl font-bold text-white font-mono truncate tracking-tight">
|
||||
{progressPct}%
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">
|
||||
{verifiedCount} of {milestones.length} verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header */}
|
||||
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<span className="text-xs text-slate-400">
|
||||
Deliverable Milestones: <strong className="text-white">{milestones.length}</strong>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && canAdd('projects_milestones') && (
|
||||
@@ -257,40 +377,23 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
{Number(editingMilestone.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Verified (Immutable)</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||
const isSelf = isMaker(editingMilestone.createdBy);
|
||||
|
||||
return (
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
|
||||
<select
|
||||
{...form.register('status')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
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}
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
|
||||
<p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Maker-Checker: Cannot verify or approve a milestone that you authored.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -331,7 +434,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
|
||||
Loading milestones...
|
||||
</div>
|
||||
@@ -339,7 +442,9 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
||||
<Flag className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<p className="text-xs font-semibold text-slate-300">No milestones registered yet</p>
|
||||
<p className="text-[11px] text-slate-500">Define contractual deliverables to monitor implementation progress.</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Define contractual deliverables to monitor implementation progress.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -349,17 +454,38 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
{milestones.map((m) => {
|
||||
const statusBadge = getApprovalStatusBadge(m.status);
|
||||
const priBadge = getPriorityBadge(m.priority);
|
||||
const isApproved = m.status === ApprovalStatus.Approved;
|
||||
|
||||
// Milestone activities
|
||||
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
const plannedMilestoneValue = mActivities.reduce(
|
||||
(sum, a) => sum + (Number(a.amount) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Milestone indicators (linked to activities under this milestone)
|
||||
const mActivityIds = new Set(mActivities.map((a) => String(a.id)));
|
||||
const mIndicators = indicators.filter(
|
||||
(i) => i.activityId && mActivityIds.has(String(i.activityId))
|
||||
);
|
||||
const actualMilestoneValue = mIndicators.reduce(
|
||||
(sum, i) => sum + (Number(i.actualCost) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const isOverrun = actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-white">{m.name}</span>
|
||||
@@ -369,6 +495,12 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
||||
{priBadge.label}
|
||||
</span>
|
||||
{isOverrun && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Budget Overrun
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -381,6 +513,13 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
||||
<div className="text-right mr-2">
|
||||
<span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
|
||||
<span className="font-mono text-xs font-bold text-blue-400 block">
|
||||
{formatZAR(plannedMilestoneValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
|
||||
@@ -400,6 +539,32 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown of Activities & Indicators */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-3 text-slate-400 text-[11px] flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Briefcase className="w-3 h-3 text-cyan-400" />
|
||||
Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-indigo-400" />
|
||||
Indicators: <strong className="text-white font-mono">{mIndicators.length}</strong>
|
||||
</span>
|
||||
<span>•</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>
|
||||
|
||||
{plannedMilestoneValue > 0 && (
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
updateProjectStatus,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
// Milestone Progress
|
||||
// Milestone Progress & Disbursements
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', project?.id],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
enabled: !!project?.id,
|
||||
});
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
// Schedule Risk
|
||||
const scheduleRisk = useMemo(() => {
|
||||
if (!project) return null;
|
||||
@@ -239,14 +252,26 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days)
|
||||
</span>
|
||||
</div>
|
||||
{approvedBudgetTotal > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-emerald-400 font-mono font-bold">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||
Project Value: {formatZAR(projectValue)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||
projectVariance >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
|
||||
>
|
||||
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||
{formatZAR(projectVariance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -505,6 +530,16 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
</span>
|
||||
<span className="text-slate-500">#{m.id}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
const mValue = mActivities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[10px] font-mono pt-1 border-t border-slate-800/60">
|
||||
<span className="text-slate-400">Milestone Value:</span>
|
||||
<span className="font-bold text-blue-400">{formatZAR(mValue)}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -549,10 +584,33 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
key={act.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
{(() => {
|
||||
const actAmount = Number(act.amount) || 0;
|
||||
const actIndicators = indicators.filter((ind) => String(ind.activityId) === String(act.id));
|
||||
const actActuals = actIndicators.reduce((sum, ind) => sum + (Number(ind.actualCost) || 0), 0);
|
||||
const actVariance = actAmount - actActuals;
|
||||
const isPositive = actVariance >= 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
||||
<span className="font-mono text-cyan-400 font-bold shrink-0">{formatZAR(act.amount)}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0 font-mono text-xs">
|
||||
<span className="font-bold text-cyan-400">{formatZAR(actAmount)}</span>
|
||||
<span
|
||||
className={`text-[9px] font-bold px-1.5 py-0.2 rounded border ${
|
||||
isPositive
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Budgeted: ${formatZAR(actAmount)} | Incurred Actuals: ${formatZAR(actActuals)} | Variance: ${isPositive ? '+' : ''}${formatZAR(actVariance)}`}
|
||||
>
|
||||
{isPositive ? `+${formatZAR(actVariance)}` : `-${formatZAR(Math.abs(actVariance))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>{act.category || 'Operations'}</span>
|
||||
<span>{formatDate(act.performedAt)}</span>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
||||
const projectMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
projects.forEach((p) => {
|
||||
map[String(p.id)] = p.name;
|
||||
map[String(p.id)] = p.name || '';
|
||||
});
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole } from '../../types';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole, ComplianceStatus } from '../../types';
|
||||
import {
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
getActivityStatusBadge,
|
||||
getUserRoleBadge,
|
||||
getComplianceStatusBadge,
|
||||
} from '../../lib/formatters';
|
||||
|
||||
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
|
||||
@@ -17,6 +18,16 @@ export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | n
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplianceStatusBadge: React.FC<{ status: ComplianceStatus | number | null | undefined }> = ({ status }) => {
|
||||
const badge = getComplianceStatusBadge(status ?? ComplianceStatus.NotMet);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border whitespace-nowrap ${badge.className}`}>
|
||||
{badge.dot && <span className={`w-1.5 h-1.5 rounded-full ${badge.dot}`} />}
|
||||
<span>{badge.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PriorityBadge: React.FC<{ priority: Priority | number | null | undefined }> = ({ priority }) => {
|
||||
const badge = getPriorityBadge(priority ?? Priority.Medium);
|
||||
return (
|
||||
|
||||
@@ -6,10 +6,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api';
|
||||
import {
|
||||
createUser,
|
||||
getSystemRoles,
|
||||
assignUserRole,
|
||||
getTenants,
|
||||
getUsers,
|
||||
notifyNewUserCreated,
|
||||
} from '../../services/api';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import { CreateUserRequest, Role, Tenant } from '../../types';
|
||||
import { CreateUserRequest, Role, Tenant, User } from '../../types';
|
||||
|
||||
const createUserSchema = z.object({
|
||||
tenantId: z.string().min(1, 'Tenant is required'),
|
||||
@@ -22,8 +29,8 @@ const createUserSchema = z.object({
|
||||
userName: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
initialRoleId: z.string().optional(),
|
||||
lockoutEnabled: z.boolean().default(false),
|
||||
twoFactorEnabled: z.boolean().default(false),
|
||||
lockoutEnabled: z.boolean(),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||
@@ -98,7 +105,9 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
||||
const assignableRoles = React.useMemo(() => {
|
||||
return systemRoles.filter((r) => {
|
||||
const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1';
|
||||
const rName = r.name || r.Name || '';
|
||||
const rId = r.id || r.Id || '';
|
||||
const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1';
|
||||
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
||||
return false;
|
||||
}
|
||||
@@ -117,8 +126,10 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
mutationFn: async (values: CreateUserFormValues) => {
|
||||
// Security enforcement: Users with tenant self-help access cannot create admin users
|
||||
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
||||
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId);
|
||||
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) {
|
||||
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||
const selName = selectedRole?.name || selectedRole?.Name || '';
|
||||
const selId = selectedRole?.id || selectedRole?.Id || '';
|
||||
if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) {
|
||||
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
||||
}
|
||||
}
|
||||
@@ -141,21 +152,39 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
|
||||
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||
|
||||
if (values.initialRoleId) {
|
||||
let createdUser: User | undefined;
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
||||
const createdUser = freshUsers.find(
|
||||
createdUser = freshUsers.find(
|
||||
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
||||
);
|
||||
if (createdUser) {
|
||||
if (createdUser && values.initialRoleId) {
|
||||
await assignUserRole(createdUser.id, values.initialRoleId);
|
||||
}
|
||||
} catch {
|
||||
// Graceful fallback
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 3: Issue InApp and Email message directing new user to appropriate portal
|
||||
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||
const roleName = selectedRole?.name || selectedRole?.Name || '';
|
||||
const isPartner =
|
||||
roleName.toLowerCase().trim() === 'partner' ||
|
||||
String(values.initialRoleId || '').toLowerCase().trim() === 'partner';
|
||||
|
||||
const targetUserId = createdUser?.id || values.idNumber.trim();
|
||||
await notifyNewUserCreated({
|
||||
userId: targetUserId,
|
||||
email: rawEmail,
|
||||
isPartner,
|
||||
roleName,
|
||||
tenantId: values.tenantId,
|
||||
}).catch(console.warn);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
|
||||
return values;
|
||||
},
|
||||
@@ -286,11 +315,15 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select Initial Role (Optional)</option>
|
||||
{assignableRoles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
{assignableRoles.map((r) => {
|
||||
const roleId = r.id || r.Id || '';
|
||||
const roleLabel = r.name || r.Name || '';
|
||||
return (
|
||||
<option key={roleId} value={roleId}>
|
||||
{roleLabel}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{hasTenantSelfHelpAccess && (
|
||||
<p className="text-[10px] text-amber-400 mt-1">
|
||||
|
||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { user: currentAuthUser } = useAuth();
|
||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||
|
||||
const userId = user?.id || '';
|
||||
const isSelf =
|
||||
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
|
||||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
|
||||
|
||||
const canManageRolesAndClaims =
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
hasTenantSelfHelpAccess ||
|
||||
isUnrestricted;
|
||||
const canChangeStatus =
|
||||
canAdd('settings_users') ||
|
||||
!isSelf &&
|
||||
(canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted;
|
||||
isUnrestricted);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||
|
||||
const userId = user?.id || '';
|
||||
|
||||
// 1. Fetch fresh user details
|
||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||
queryKey: ['user-detail', userId],
|
||||
@@ -237,20 +243,35 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
onError: (err: unknown) => toastError('Logout Failed', err),
|
||||
});
|
||||
|
||||
const lockoutMutation = useMutation({
|
||||
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
|
||||
onSuccess: (_, disableLock) => {
|
||||
success(
|
||||
disableLock ? 'Account Unlocked' : 'Account Locked',
|
||||
`User account lockout status successfully modified.`
|
||||
);
|
||||
const lockUserMutation = useMutation({
|
||||
mutationFn: () => setUserLockout(userId, true),
|
||||
onSuccess: () => {
|
||||
success('Account Locked', 'User account lockout enabled (disable = true).');
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Lockout Update Failed', err),
|
||||
onError: (err: unknown) => toastError('Lock User Failed', err),
|
||||
});
|
||||
|
||||
const unlockUserMutation = useMutation({
|
||||
mutationFn: () => setUserLockout(userId, false),
|
||||
onSuccess: () => {
|
||||
success('Account Unlocked', 'User account lockout disabled (disable = false).');
|
||||
refetchUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
||||
},
|
||||
onError: (err: unknown) => toastError('Unlock User Failed', err),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
||||
mutationFn: (newStatus: ActivityStatus) => {
|
||||
if (isSelf) {
|
||||
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
|
||||
}
|
||||
return updateUserStatus(userId, newStatus);
|
||||
},
|
||||
onSuccess: () => {
|
||||
success('Status Updated', 'User activity status changed.');
|
||||
refetchUser();
|
||||
@@ -280,18 +301,18 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
|
||||
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
|
||||
title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
|
||||
subtitle="Manage account status, role assignment, permissions, and active sessions."
|
||||
>
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-2 border-b border-slate-800 pb-2">
|
||||
<div className="flex gap-1.5 border-b border-slate-800 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
@@ -300,21 +321,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('roles')}
|
||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'roles'
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
<span>Roles ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
||||
<span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('claims')}
|
||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'claims'
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
@@ -324,9 +345,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('sessions')}
|
||||
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
||||
activeTab === 'sessions'
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-blue-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
@@ -386,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
||||
{isSelf ? (
|
||||
<span
|
||||
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
|
||||
title="Maker-Checker policy: Administrators cannot change their own account activity status"
|
||||
>
|
||||
Self-Account (Protected)
|
||||
</span>
|
||||
) : effectiveUser?.status === ActivityStatus.Active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||
@@ -592,70 +620,66 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Sessions & Direct Account Interventions */}
|
||||
{/* TAB 4: Sessions & Security Interventions */}
|
||||
{activeTab === 'sessions' && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
||||
{/* Force Logout Action */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||
<LogOut className="w-4 h-4 text-amber-400" />
|
||||
<span>Revoke Active User Session</span>
|
||||
<HelpTooltip
|
||||
title="User Session Invalidation"
|
||||
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Terminates all active JWT tokens and sessions immediately without direct database intervention.
|
||||
Immediately invalidates active JWT tokens and logs the user out.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lock / Unlock Account */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||
<Lock className="w-4 h-4 text-rose-400" />
|
||||
<span>Account Lockout Control</span>
|
||||
<HelpTooltip
|
||||
title="Account Lockout Control"
|
||||
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
<span>Account Lockout</span>
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
|
||||
Control tenant login authorization for this account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lockoutMutation.mutate(false)}
|
||||
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lockUserMutation.mutate();
|
||||
}}
|
||||
disabled={lockUserMutation.isPending || unlockUserMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Lock User
|
||||
{lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lockoutMutation.mutate(true)}
|
||||
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
unlockUserMutation.mutate();
|
||||
}}
|
||||
disabled={unlockUserMutation.isPending || lockUserMutation.isPending || !canManageSessions}
|
||||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Unlock User
|
||||
{unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { updateHasherSettings } from './services/toolsService';
|
||||
import { updateHasherSettings } from './utils/hashids';
|
||||
|
||||
export interface SecuritySettings {
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
ClientSecret?: string;
|
||||
Scope?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ApiSettings {
|
||||
}
|
||||
|
||||
export interface HasherSettings {
|
||||
Salt: string;
|
||||
Salt?: string;
|
||||
MinHashLength: number;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export const defaultSecuritySettings: SecuritySettings = {
|
||||
Authority: 'https://sts.security.khongisa.co.za',
|
||||
Audience: 'pfm-api-dev',
|
||||
ClientId: 'pfm-console-dev',
|
||||
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Scope: 'openid profile roles email address pfm-api-dev',
|
||||
};
|
||||
|
||||
@@ -49,6 +48,10 @@ export const defaultHasherSettings: HasherSettings = {
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
||||
updateHasherSettings(salt, minHashLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts first non-empty, trimmed string from candidate values.
|
||||
*/
|
||||
@@ -255,9 +258,8 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
windowConfig.clientSecret,
|
||||
fetchedConfig.clientSecret,
|
||||
buildEnvConfig.clientSecret,
|
||||
defaultSecuritySettings.ClientSecret
|
||||
)!;
|
||||
buildEnvConfig.clientSecret
|
||||
);
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
windowConfig.scope,
|
||||
@@ -278,7 +280,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
fetchedConfig.hasherSalt,
|
||||
buildEnvConfig.hasherSalt,
|
||||
defaultHasherSettings.Salt
|
||||
)!;
|
||||
);
|
||||
|
||||
const minHashLength =
|
||||
windowConfig.hasherMinLength ||
|
||||
@@ -291,14 +293,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
Authority: normalizeAuthorityUrl(authority),
|
||||
Audience: audience,
|
||||
ClientId: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
||||
Scope: scope,
|
||||
},
|
||||
ApiSettings: {
|
||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: salt,
|
||||
Salt: salt || defaultHasherSettings.Salt,
|
||||
MinHashLength: minHashLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
@@ -310,7 +312,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
let runtimeConfig: AppConfig = resolveConfig();
|
||||
|
||||
// Immediately apply deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
@@ -360,7 +362,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
|
||||
// Synchronize deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
console.info(
|
||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import {
|
||||
getUserNotifications,
|
||||
getNotifications,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
archiveNotification,
|
||||
updateNotification,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Notification,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
CreateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
export interface DraftMessage {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { user, claims } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = (user?.id || claims?.sub || '') as string;
|
||||
const userEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
// Local storage keys for client-side starred and drafts
|
||||
const draftsStorageKey = `pfm_mailbox_drafts_${userId || 'default'}`;
|
||||
const starredStorageKey = `pfm_mailbox_starred_${userId || 'default'}`;
|
||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||
|
||||
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
const parsed: T[] = stored ? JSON.parse(stored) : [];
|
||||
if (fallbackKey && fallbackKey !== key) {
|
||||
const fallbackStored = localStorage.getItem(fallbackKey);
|
||||
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
|
||||
if (fallbackParsed.length > 0) {
|
||||
const merged = [...parsed];
|
||||
for (const item of fallbackParsed) {
|
||||
const itemId = (item as any)?.id ?? item;
|
||||
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
localStorage.removeItem(fallbackKey);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Starred messages set
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
|
||||
);
|
||||
|
||||
// Trashed messages set
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
|
||||
);
|
||||
|
||||
// Local drafts list
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
|
||||
);
|
||||
|
||||
// Local sent messages history
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
|
||||
);
|
||||
|
||||
// Synchronize across hook instances and userId changes
|
||||
const syncFromStorage = useCallback(() => {
|
||||
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
|
||||
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
|
||||
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
|
||||
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
|
||||
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFromStorage();
|
||||
}, [syncFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSync = () => syncFromStorage();
|
||||
window.addEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.addEventListener('storage', handleSync);
|
||||
return () => {
|
||||
window.removeEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.removeEventListener('storage', handleSync);
|
||||
};
|
||||
}, [syncFromStorage]);
|
||||
|
||||
// Fetch notifications from backend
|
||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||
queryKey: ['inapp-notifications', userId],
|
||||
queryFn: async () => {
|
||||
// 1. Try user-specific notifications
|
||||
let list = await getUserNotifications(userId || undefined);
|
||||
// 2. If list is empty or fallback needed, also check organisation notifications
|
||||
if (!list || list.length === 0) {
|
||||
const orgList = await getNotifications();
|
||||
if (orgList && orgList.length > 0) {
|
||||
list = orgList;
|
||||
}
|
||||
}
|
||||
return list || [];
|
||||
},
|
||||
// Live polling every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||
const inAppNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
|
||||
);
|
||||
}, [rawNotifications]);
|
||||
|
||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n =>
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
Number(n.status) !== NotificationStatus.Archived &&
|
||||
Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Active mailbox notifications (always In-App notifications)
|
||||
const allMailboxNotifications = inboxNotifications;
|
||||
|
||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||
const unreadNotifications = useMemo(() => {
|
||||
return inboxNotifications.filter(
|
||||
n =>
|
||||
Number(n.status) !== NotificationStatus.Read &&
|
||||
Number(n.status) !== NotificationStatus.Cancelled &&
|
||||
Number(n.status) !== NotificationStatus.Failed
|
||||
);
|
||||
}, [inboxNotifications]);
|
||||
|
||||
const unreadCount = unreadNotifications.length;
|
||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||
|
||||
// Starred notifications (In-App only)
|
||||
const starredNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n =>
|
||||
starredIds.includes(String(n.id)) &&
|
||||
!trashedIds.includes(String(n.id))
|
||||
);
|
||||
}, [inAppNotifications, starredIds, trashedIds]);
|
||||
|
||||
// Trashed notifications (In-App only)
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (id: number | string) => {
|
||||
await markNotificationAsRead(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all unread as read
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unread = unreadNotifications;
|
||||
for (const item of unread) {
|
||||
try {
|
||||
await markNotificationAsRead(item.id);
|
||||
} catch {}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}, [unreadNotifications, queryClient]);
|
||||
|
||||
// Toggle star
|
||||
const toggleStar = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setStarredIds(prev => {
|
||||
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(starredStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [starredStorageKey]);
|
||||
|
||||
// Delete message (NotificationStatus == Archived & move to trash)
|
||||
const deleteMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await archiveNotification(id);
|
||||
} catch (err) {
|
||||
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
// Restore from trash / unarchive
|
||||
const restoreMessage = useCallback(async (id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
|
||||
try {
|
||||
await markNotificationAsRead(id);
|
||||
} catch (err) {
|
||||
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}
|
||||
}, [trashStorageKey, userId, queryClient]);
|
||||
|
||||
// Permanent delete
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
setLocalSent(prev => {
|
||||
const next = prev.filter(n => String(n.id) !== strId);
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey, sentStorageKey]);
|
||||
|
||||
// Draft management
|
||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||
const draftId = draft.id || `draft_${Date.now()}`;
|
||||
const newDraft: DraftMessage = {
|
||||
...draft,
|
||||
id: draftId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const filtered = base.filter(d => d.id !== draftId);
|
||||
const next = [newDraft, ...filtered];
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
return draftId;
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
const deleteDraft = useCallback((id: string) => {
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const next = base.filter(d => d.id !== id);
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
// Send new notification (InApp or Email)
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
const orgId = getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'System User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
message: payload.message,
|
||||
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
|
||||
};
|
||||
|
||||
await createNotification(req);
|
||||
|
||||
// Record in local sent messages
|
||||
const sentItem: Notification = {
|
||||
id: `sent_${Date.now()}`,
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
status: NotificationStatus.Sent,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
destination: formattedDestination,
|
||||
createdAt: new Date().toISOString(),
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLocalSent(prev => {
|
||||
const next = [sentItem, ...prev];
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
// If sending a draft, remove the draft
|
||||
if (payload.draftId) {
|
||||
deleteDraft(payload.draftId);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rawNotifications,
|
||||
inAppNotifications,
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadNotifications,
|
||||
unreadCount,
|
||||
top5Unread,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications: localSent,
|
||||
drafts,
|
||||
starredIds,
|
||||
trashedIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
|
||||
markAllAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
sendMessage: sendMessageMutation.mutateAsync,
|
||||
isSending: sendMessageMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApprovalStatus,
|
||||
ComplianceStatus,
|
||||
ContractStatus,
|
||||
ActivityStatus,
|
||||
Priority,
|
||||
@@ -78,6 +79,8 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Failed, label: 'Failed' },
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
@@ -271,6 +274,37 @@ export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getComplianceStatusBadge = (status: ComplianceStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ComplianceStatus.Exceeded:
|
||||
return {
|
||||
label: 'Exceeded Expectation',
|
||||
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 font-semibold',
|
||||
dot: 'bg-purple-400',
|
||||
};
|
||||
case ComplianceStatus.Met:
|
||||
return {
|
||||
label: 'Met',
|
||||
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 font-semibold',
|
||||
dot: 'bg-emerald-400',
|
||||
};
|
||||
case ComplianceStatus.NotMet:
|
||||
default:
|
||||
return {
|
||||
label: 'Not Met',
|
||||
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 font-semibold',
|
||||
dot: 'bg-rose-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const COMPLIANCE_STATUS_OPTIONS = [
|
||||
{ value: ComplianceStatus.NotMet, label: 'Not Met' },
|
||||
{ value: ComplianceStatus.Met, label: 'Met' },
|
||||
{ value: ComplianceStatus.Exceeded, label: 'Exceeded Expectation' },
|
||||
];
|
||||
|
||||
export const getContractStatusBadge = (status: ContractStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
@@ -477,6 +511,10 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Archived:
|
||||
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
case NotificationStatus.Read:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
|
||||
*/
|
||||
|
||||
export const PFM_BRAND_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="40" height="40" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="pfm-ambient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pfm-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#a5b4fc" />
|
||||
<stop offset="35%" stop-color="#6366f1" />
|
||||
<stop offset="100%" stop-color="#4338ca" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="45%" stop-color="#2563eb" />
|
||||
<stop offset="100%" stop-color="#1e40af" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#22d3ee" />
|
||||
<stop offset="50%" stop-color="#06b6d4" />
|
||||
<stop offset="100%" stop-color="#10b981" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="40%" stop-color="#e0f2fe" />
|
||||
<stop offset="100%" stop-color="#38bdf8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient)" />
|
||||
<path d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z" stroke="#3b82f6" stroke-opacity="0.3" stroke-width="1.2" fill="none" />
|
||||
<path d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z" fill="url(#pfm-top)" />
|
||||
<path d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z" fill="url(#pfm-left)" />
|
||||
<path d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z" fill="url(#pfm-right)" />
|
||||
<path d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z" fill="#0b1120" stroke="#334155" stroke-width="1" />
|
||||
<path d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z" fill="url(#pfm-top)" opacity="0.9" />
|
||||
<path d="M25 37 L32 41 L39 37 L32 33 L25 37 Z" fill="url(#pfm-right)" opacity="0.9" />
|
||||
<polygon points="32,27 36.5,32 32,37 27.5,32" fill="url(#pfm-core)" />
|
||||
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||
</svg>`;
|
||||
|
||||
export const PFM_LOGO_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(PFM_BRAND_LOGO_SVG)}`;
|
||||
|
||||
export interface MarkdownRenderOptions {
|
||||
applyBranding?: boolean;
|
||||
emailFormat?: boolean;
|
||||
subject?: string;
|
||||
tenantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the content string is formatted HTML or Markdown.
|
||||
*/
|
||||
export function isHtmlContent(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
trimmed.startsWith('<!DOCTYPE') ||
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<p') ||
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<ul') ||
|
||||
trimmed.startsWith('<ol') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
(trimmed.includes('</') && trimmed.includes('>')) ||
|
||||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts raw plain text from markdown or HTML for concise notification snippets.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ') // Strip HTML tags
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Strip images
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Extract link text
|
||||
.replace(/#{1,6}\s+/g, '') // Strip headers
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Strip bold
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2') // Strip italic
|
||||
.replace(/~~(.*?)~~/g, '$1') // Strip strikethrough
|
||||
.replace(/`{1,3}.*?`{1,3}/gs, '') // Strip code blocks
|
||||
.replace(/^>\s+/gm, '') // Strip blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // Strip list markers
|
||||
.replace(/^\d+\.\s+/gm, '') // Strip ordered list markers
|
||||
.replace(/\|/g, ' ') // Replace table pipes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown text into semantic HTML.
|
||||
*/
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown
|
||||
// Sanitize script tags
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
// Normalize line endings
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
// 1. Code blocks (```lang\ncode\n```)
|
||||
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// 2. Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<code style="background:#1e293b;color:#38bdf8;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
||||
});
|
||||
|
||||
// 3. Tables (| A | B |\n|---|---|\n| C | D |)
|
||||
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
|
||||
const rows = match.trim().split('\n');
|
||||
if (rows.length < 2) return match;
|
||||
|
||||
const hasSeparator = rows[1] && rows[1].includes('---');
|
||||
if (!hasSeparator) return match;
|
||||
|
||||
const parseCells = (row: string) =>
|
||||
row
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseCells(rows[0]);
|
||||
const bodyRows = rows.slice(2);
|
||||
|
||||
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
|
||||
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
|
||||
headers.forEach((h) => {
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
|
||||
});
|
||||
tableHtml += `</tr></thead><tbody>`;
|
||||
|
||||
bodyRows.forEach((row, i) => {
|
||||
const cells = parseCells(row);
|
||||
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
|
||||
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
|
||||
cells.forEach((c) => {
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
|
||||
});
|
||||
tableHtml += `</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table></div>`;
|
||||
return tableHtml;
|
||||
});
|
||||
|
||||
// 4. Headers (# through ####)
|
||||
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
|
||||
|
||||
// 5. Blockquotes and GitHub alerts (> [!NOTE], > [!IMPORTANT], etc.)
|
||||
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #3b82f6;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// 6. Horizontal Rules (---)
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
|
||||
|
||||
// 7. Bold & Italic
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
|
||||
|
||||
// 8. Images and Links
|
||||
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:10px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);" />');
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#38bdf8;text-decoration:underline;">$1</a>');
|
||||
|
||||
// 9. Unordered Lists (- or *)
|
||||
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 10. Ordered Lists (1. 2.)
|
||||
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 11. Paragraphs
|
||||
const paragraphs = html.split(/\n{2,}/);
|
||||
html = paragraphs
|
||||
.map((p) => {
|
||||
const trimmed = p.trim();
|
||||
if (!trimmed) return '';
|
||||
if (
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<pre') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
trimmed.startsWith('<hr') ||
|
||||
trimmed.startsWith('<li')
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Markdown into full branded executive HTML for email notifications.
|
||||
*/
|
||||
export function renderMarkdownToHtml(
|
||||
markdown: string,
|
||||
options?: MarkdownRenderOptions
|
||||
): string {
|
||||
const trimmed = (markdown || '').trim();
|
||||
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const contentHtml = isHtmlContent(trimmed)
|
||||
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
: parseMarkdownToHtml(trimmed);
|
||||
|
||||
if (!options?.applyBranding && !options?.emailFormat) {
|
||||
return contentHtml;
|
||||
}
|
||||
|
||||
const subject = options?.subject || 'Statutory Platform Notification';
|
||||
const tenantName = options?.tenantName || 'Post-Fund Management (PFM) Platform';
|
||||
const dateFormatted = new Date().toLocaleDateString('en-ZA', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${subject}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px 12px;background-color:#090d16;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#cbd5e1;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background-color:#0d1322;border:1px solid #1e293b;border-radius:16px;overflow:hidden;box-shadow:0 12px 36px rgba(0,0,0,0.6);">
|
||||
<!-- Executive Brand Header -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;background:linear-gradient(135deg, #0b1120 0%, #1e1b4b 50%, #0f172a 100%);border-bottom:1px solid #1e293b;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="48" style="vertical-align:middle;">
|
||||
<img src="${PFM_LOGO_DATA_URI}" alt="PFM Logo" width="40" height="40" style="display:block;border-radius:10px;" />
|
||||
</td>
|
||||
<td style="padding-left:14px;vertical-align:middle;">
|
||||
<div style="font-size:14px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;text-transform:uppercase;">
|
||||
Post-Fund Management (PFM)
|
||||
</div>
|
||||
<div style="font-size:11px;color:#38bdf8;font-weight:600;margin-top:2px;">
|
||||
${tenantName}
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;">
|
||||
<span style="font-size:10px;font-weight:700;color:#38bdf8;background-color:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.25);padding:4px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:0.04em;">
|
||||
Official Notice
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Subject Banner -->
|
||||
<tr>
|
||||
<td style="padding:16px 24px;background-color:#0b101b;border-bottom:1px solid #1e293b;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#ffffff;line-height:1.4;">
|
||||
${subject}
|
||||
</h2>
|
||||
<div style="font-size:11px;color:#64748b;margin-top:4px;">
|
||||
Dispatched on ${dateFormatted} • Electronic System Communication
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Message Content Body -->
|
||||
<tr>
|
||||
<td style="padding:24px;font-size:13px;line-height:1.65;color:#e2e8f0;">
|
||||
${contentHtml}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Executive Footer -->
|
||||
<tr>
|
||||
<td style="padding:18px 24px;background-color:#080c14;border-top:1px solid #1e293b;font-size:11px;color:#64748b;text-align:center;">
|
||||
<div style="font-weight:600;color:#94a3b8;margin-bottom:4px;">
|
||||
Post-Fund Management, Performance & Impact Governance Platform
|
||||
</div>
|
||||
<div>
|
||||
This message was transmitted securely under active multi-tenant encryption. Please do not reply directly to automated dispatch notices.
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;color:#475569;">
|
||||
© ${new Date().getFullYear()} PFM Governance Architecture. All rights reserved.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
// Remove caching from all pages: zero stale time and zero cache retention
|
||||
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||
const RETAINED_CACHE_OPTIONS = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Resolved & Enacted (Immutable)</span>
|
||||
<span>Resolved & Enacted</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
title="Approved and enacted change requests are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Enacted</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Building2,
|
||||
FileSpreadsheet,
|
||||
Download,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
getAllProgrammes,
|
||||
getAllPortfolios,
|
||||
getBudgets,
|
||||
getProjectComplianceItems,
|
||||
getProjectBeneficiaries,
|
||||
getBeneficiaryOutcomes,
|
||||
getProjectRisks,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Project,
|
||||
Programme,
|
||||
Portfolio,
|
||||
ComplianceItem,
|
||||
Beneficiary,
|
||||
Outcome,
|
||||
ComplianceStatus,
|
||||
ApprovalStatus,
|
||||
} from '../types';
|
||||
import {
|
||||
formatDate,
|
||||
formatZAR,
|
||||
formatCompactZAR,
|
||||
getIndustrySectorLabel,
|
||||
getProjectTypeLabel,
|
||||
getApprovalStatusBadge,
|
||||
} from '../lib/formatters';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ComplianceStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
|
||||
export const CompliancePage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
||||
const [selectedComplianceFilter, setSelectedComplianceFilter] = useState<'All' | 'Compliant' | 'Noncompliant'>('All');
|
||||
const [selectedProjectForAudit, setSelectedProjectForAudit] = useState<Project | null>(null);
|
||||
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: projects = [], isLoading: loadingProjects, refetch: refetchProjects, isFetching } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
|
||||
const { data: programmes = [] } = useQuery({
|
||||
queryKey: ['programmes'],
|
||||
queryFn: () => getAllProgrammes(),
|
||||
});
|
||||
|
||||
const { data: portfolios = [] } = useQuery({
|
||||
queryKey: ['portfolios'],
|
||||
queryFn: () => getAllPortfolios(),
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 200),
|
||||
});
|
||||
|
||||
const { data: beneficiariesMap = new Map<string | number, Beneficiary[]>() } = useQuery({
|
||||
queryKey: ['allProjectBeneficiaries', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, Beneficiary[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const bens = await getProjectBeneficiaries(p.id);
|
||||
map.set(p.id, bens);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
// Query compliance items for all projects
|
||||
const { data: complianceMap = new Map<string | number, ComplianceItem[]>(), isLoading: loadingComps } = useQuery({
|
||||
queryKey: ['allProjectCompliance', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, ComplianceItem[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const items = await getProjectComplianceItems(p.id);
|
||||
map.set(p.id, items);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
// Query beneficiary outcomes
|
||||
const allBensList = useMemo(() => {
|
||||
const list: Beneficiary[] = [];
|
||||
beneficiariesMap.forEach((bList) => list.push(...bList));
|
||||
return list;
|
||||
}, [beneficiariesMap]);
|
||||
|
||||
const { data: outcomesMap = new Map<string | number, Outcome[]>() } = useQuery({
|
||||
queryKey: ['allBeneficiaryOutcomes', allBensList.map((b) => b.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, Outcome[]>();
|
||||
await Promise.all(
|
||||
allBensList.map(async (b) => {
|
||||
try {
|
||||
const outs = await getBeneficiaryOutcomes(b.id);
|
||||
map.set(b.id, outs);
|
||||
} catch {
|
||||
map.set(b.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: allBensList.length > 0,
|
||||
});
|
||||
|
||||
// Lookups
|
||||
const programmeMap = useMemo(() => {
|
||||
const map = new Map<string | number, Programme>();
|
||||
programmes.forEach((pr) => map.set(pr.id, pr));
|
||||
return map;
|
||||
}, [programmes]);
|
||||
|
||||
// Aggregate project compliance records
|
||||
const projectComplianceStats = useMemo(() => {
|
||||
return projects.map((p) => {
|
||||
const prog = programmeMap.get(p.programmeId);
|
||||
const items = complianceMap.get(p.id) || [];
|
||||
const projectBens = beneficiariesMap.get(p.id) || [];
|
||||
|
||||
let totalOutcomes = 0;
|
||||
let metOutcomes = 0;
|
||||
projectBens.forEach((b) => {
|
||||
const bOutcomes = outcomesMap.get(b.id) || [];
|
||||
totalOutcomes += bOutcomes.length;
|
||||
metOutcomes += bOutcomes.filter(
|
||||
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
});
|
||||
|
||||
const totalItems = items.length;
|
||||
const metItems = items.filter(
|
||||
(i) => Number(i.status) === ComplianceStatus.Met || Number(i.status) === ComplianceStatus.Exceeded
|
||||
).length;
|
||||
const unmetItems = totalItems - metItems;
|
||||
const unmetOutcomes = totalOutcomes - metOutcomes;
|
||||
|
||||
const isCompliant = unmetItems === 0 && unmetOutcomes === 0;
|
||||
|
||||
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const approvedBudget = projectBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const budgetAtRisk = isCompliant ? 0 : approvedBudget;
|
||||
|
||||
return {
|
||||
project: p,
|
||||
programme: prog,
|
||||
totalItems,
|
||||
metItems,
|
||||
unmetItems,
|
||||
totalOutcomes,
|
||||
metOutcomes,
|
||||
unmetOutcomes,
|
||||
beneficiariesCount: projectBens.length,
|
||||
isCompliant,
|
||||
approvedBudget,
|
||||
budgetAtRisk,
|
||||
};
|
||||
});
|
||||
}, [projects, programmeMap, complianceMap, beneficiariesMap, outcomesMap, allBudgets]);
|
||||
|
||||
// Filtered rows
|
||||
const filteredRows = useMemo(() => {
|
||||
return projectComplianceStats.filter((row) => {
|
||||
if (selectedProgramme !== 'All' && String(row.project.programmeId) !== selectedProgramme) {
|
||||
return false;
|
||||
}
|
||||
if (selectedComplianceFilter === 'Compliant' && !row.isCompliant) {
|
||||
return false;
|
||||
}
|
||||
if (selectedComplianceFilter === 'Noncompliant' && row.isCompliant) {
|
||||
return false;
|
||||
}
|
||||
if (searchTerm.trim()) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const pName = (row.project.name || '').toLowerCase();
|
||||
const prName = (row.programme?.name || '').toLowerCase();
|
||||
const idStr = String(row.project.id);
|
||||
if (!pName.includes(q) && !prName.includes(q) && !idStr.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projectComplianceStats, selectedProgramme, selectedComplianceFilter, searchTerm]);
|
||||
|
||||
// Aggregate KPI figures
|
||||
const totalProjectsCount = projectComplianceStats.length;
|
||||
const compliantProjectsCount = projectComplianceStats.filter((r) => r.isCompliant).length;
|
||||
const noncompliantProjectsCount = totalProjectsCount - compliantProjectsCount;
|
||||
const overallComplianceRate = totalProjectsCount > 0 ? Math.round((compliantProjectsCount / totalProjectsCount) * 100) : 100;
|
||||
const totalBudgetAtRisk = projectComplianceStats.reduce((sum, r) => sum + r.budgetAtRisk, 0);
|
||||
const totalBeneficiaries = allBensList.length;
|
||||
|
||||
const complianceExportColumns: ExportColumn<any>[] = [
|
||||
{ header: 'Project ID', accessor: (r) => r.project.id },
|
||||
{ header: 'Project Name', accessor: (r) => r.project.name },
|
||||
{ header: 'Programme', accessor: (r) => r.programme?.name || `Prog #${r.project.programmeId}` },
|
||||
{ header: 'Sector', accessor: (r) => getIndustrySectorLabel(r.project.sector) },
|
||||
{ header: 'Compliance Status', accessor: (r) => (r.isCompliant ? '100% Compliant' : 'Noncompliant') },
|
||||
{ header: 'Requirements Met', accessor: (r) => `${r.metItems}/${r.totalItems}` },
|
||||
{ header: 'Outcomes Met', accessor: (r) => `${r.metOutcomes}/${r.totalOutcomes}` },
|
||||
{ header: 'Approved Budget', accessor: (r) => formatZAR(r.approvedBudget) },
|
||||
{ header: 'Budget At Risk', accessor: (r) => formatZAR(r.budgetAtRisk) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Banner */}
|
||||
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 rounded-3xl border border-slate-800 shadow-xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-blue-400">
|
||||
Statutory Governance & Compliance
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
||||
Portfolio Compliance Dashboard
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-400 max-w-2xl">
|
||||
Enterprise-wide compliance audit across all funded projects, beneficiary impact outcomes, and capital at risk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 relative z-10 self-start md:self-center">
|
||||
<ExportButton
|
||||
claim="compliance"
|
||||
data={filteredRows}
|
||||
columns={complianceExportColumns}
|
||||
filename={`portfolio_compliance_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Portfolio Compliance Register"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchProjects()}
|
||||
disabled={isFetching}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-600/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5 Headline KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{/* Card 1: Overall Rate */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance Rate</span>
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{overallComplianceRate}%</p>
|
||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">100% threshold target</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Compliant Projects */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliant</span>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{compliantProjectsCount}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Eligible for approval</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Noncompliant Projects */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Noncompliant</span>
|
||||
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-rose-400 tracking-tight">{noncompliantProjectsCount}</p>
|
||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Approvals blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Total Capital At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Capital At Risk</span>
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Beneficiaries Monitored */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
||||
<div className="p-1.5 rounded-lg bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{totalBeneficiaries}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Impact groups monitored</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Toolbar */}
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-lg flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search project, programme, ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 pr-3 py-1.5 bg-[#090d16] border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-64 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
||||
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Programme:</span>
|
||||
<select
|
||||
value={selectedProgramme}
|
||||
onChange={(e) => setSelectedProgramme(e.target.value)}
|
||||
className="bg-transparent text-white focus:outline-none cursor-pointer max-w-[180px] truncate"
|
||||
>
|
||||
<option value="All" className="bg-slate-900">All Programmes</option>
|
||||
{programmes.map((pr) => (
|
||||
<option key={pr.id} value={pr.id} className="bg-slate-900">
|
||||
{pr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
||||
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Compliance:</span>
|
||||
<select
|
||||
value={selectedComplianceFilter}
|
||||
onChange={(e) => setSelectedComplianceFilter(e.target.value as any)}
|
||||
className="bg-transparent text-white focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="All" className="bg-slate-900">All Statuses</option>
|
||||
<option value="Compliant" className="bg-slate-900">Compliant (100%)</option>
|
||||
<option value="Noncompliant" className="bg-slate-900">Noncompliant (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
Showing <strong className="text-white">{filteredRows.length}</strong> of {projectComplianceStats.length} projects
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Projects Compliance Register Table */}
|
||||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-[#0d121f]/90">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
Project Compliance Matrix & Audit Register
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Invariant: 100% compliance required for formal project approval
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#090d16] border-b border-slate-800 text-slate-400 text-[11px] font-semibold select-none">
|
||||
<th className="py-3.5 px-5">Project Name & ID</th>
|
||||
<th className="py-3.5 px-4">Programme</th>
|
||||
<th className="py-3.5 px-4">Compliance Status</th>
|
||||
<th className="py-3.5 px-4 text-center">Requirements Met</th>
|
||||
<th className="py-3.5 px-4 text-center">Outcomes Met</th>
|
||||
<th className="py-3.5 px-4 text-right">Approved Budget</th>
|
||||
<th className="py-3.5 px-4 text-right">Capital At Risk</th>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||
{loadingProjects || loadingComps ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-400 text-xs">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading compliance matrix...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-10 text-center text-slate-500 text-xs">
|
||||
No projects matching active compliance filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRows.map((r) => (
|
||||
<tr key={r.project.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
<span className="font-semibold text-white block">{r.project.name}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID #{r.project.id} • {getIndustrySectorLabel(r.project.sector)}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">
|
||||
{r.programme?.name || `Prog #${r.project.programmeId}`}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{r.isCompliant ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
<span>100% Compliant</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Noncompliant ({r.unmetItems + r.unmetOutcomes} Unmet)</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
||||
<span className={r.unmetItems === 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{r.metItems}/{r.totalItems}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
||||
<span className={r.unmetOutcomes === 0 ? 'text-emerald-400' : 'text-purple-400'}>
|
||||
{r.metOutcomes}/{r.totalOutcomes}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-300">
|
||||
{formatZAR(r.approvedBudget)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold">
|
||||
<span className={r.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{formatZAR(r.budgetAtRisk)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProjectForAudit(r.project);
|
||||
setIsAuditModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 px-3 py-1.5 rounded-xl transition cursor-pointer shadow-xs active:scale-95"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Inspect Compliance</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Audit Modal */}
|
||||
<ProjectComplianceModal
|
||||
isOpen={isAuditModalOpen}
|
||||
onClose={() => {
|
||||
setIsAuditModalOpen(false);
|
||||
setSelectedProjectForAudit(null);
|
||||
}}
|
||||
project={selectedProjectForAudit}
|
||||
programme={selectedProjectForAudit ? programmeMap.get(selectedProjectForAudit.programmeId) || null : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ShieldAlert,
|
||||
RotateCcw,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getDashboard,
|
||||
@@ -26,9 +27,10 @@ import {
|
||||
getDisbursements,
|
||||
getAllRisks,
|
||||
getAllIndicators,
|
||||
getProjectComplianceItems,
|
||||
} from '../services/api';
|
||||
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
|
||||
import { ApprovalStatus } from '../types';
|
||||
import { ApprovalStatus, ComplianceStatus } from '../types';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
@@ -39,6 +41,10 @@ interface AttentionProject {
|
||||
programme: string;
|
||||
status: string;
|
||||
risk: string;
|
||||
complianceStatus: string;
|
||||
isCompliant: boolean;
|
||||
unmetCount: number;
|
||||
budgetAtRisk: number;
|
||||
financialVariance: string;
|
||||
varianceType: string;
|
||||
maxScore: number;
|
||||
@@ -108,11 +114,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
// Filtered projects according to active Programme and Sector dropdown selections
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
@@ -127,6 +128,30 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
});
|
||||
}, [projects, selectedProgramme, selectedSector]);
|
||||
|
||||
const { data: projectComplianceMap = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['projectComplianceMap', filteredProjects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, any[]>();
|
||||
await Promise.all(
|
||||
filteredProjects.map(async (p) => {
|
||||
try {
|
||||
const items = await getProjectComplianceItems(p.id);
|
||||
map.set(p.id, items);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: filteredProjects.length > 0,
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
// Filtered risks associated with the filtered project set
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (selectedProgramme === 'All' && selectedSector === 'All') {
|
||||
@@ -189,6 +214,11 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.disbursed || 0;
|
||||
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
const disbursedPercentage = useMemo(() => {
|
||||
if (!portfolioValue || portfolioValue <= 0) return 0;
|
||||
return Math.min(100, Math.round((disbursed / portfolioValue) * 1000) / 10);
|
||||
}, [disbursed, portfolioValue]);
|
||||
|
||||
const onTrackPercentage = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 0;
|
||||
@@ -217,6 +247,34 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.atRiskCount ?? 0;
|
||||
}, [dashboard, filteredRisks]);
|
||||
|
||||
// Compliance and Capital at Risk calculations
|
||||
const totalBudgetAtRisk = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
return targetProjects.reduce((sum, p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const hasUnmet = pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
const isPendingOrRejected = p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending;
|
||||
if (hasUnmet || isPendingOrRejected) {
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApproved = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((bSum, b) => bSum + (Number(b.amount) || 0), 0);
|
||||
return sum + pApproved;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [filteredProjects, projects, projectComplianceMap, budgets]);
|
||||
|
||||
const complianceRate = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 100;
|
||||
const compliantCount = targetProjects.filter((p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
return !pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
}).length;
|
||||
return Math.round((compliantCount / targetProjects.length) * 100);
|
||||
}, [filteredProjects, projects, projectComplianceMap]);
|
||||
|
||||
const beneficiariesReached = useMemo(() => {
|
||||
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
|
||||
return dashboard.beneficiariesReached;
|
||||
@@ -288,7 +346,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
}, [disbursements]);
|
||||
|
||||
// Projects requiring management attention based on risk profile
|
||||
const attentionProjects = useMemo(() => {
|
||||
const attentionProjects = useMemo<AttentionProject[]>(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return [];
|
||||
|
||||
@@ -303,7 +361,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
return targetProjects
|
||||
.map((p) => {
|
||||
.map((p): AttentionProject => {
|
||||
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
||||
const isRejected = p.status === ApprovalStatus.Rejected;
|
||||
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
||||
@@ -325,21 +383,38 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
varianceType = 'negative';
|
||||
}
|
||||
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const unmetCount = pComps.filter(
|
||||
(c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1
|
||||
).length;
|
||||
const isCompliant = unmetCount === 0;
|
||||
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApprovedBudget = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const projectBudgetAtRisk = !isCompliant || riskLevel === 'RED' || riskLevel === 'AMBER' ? pApprovedBudget : 0;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
name: p.name || 'Unnamed Project',
|
||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||
status: statusLabel,
|
||||
risk: riskLevel,
|
||||
complianceStatus: isCompliant ? 'Compliant' : `Noncompliant (${unmetCount} Unmet)`,
|
||||
isCompliant,
|
||||
unmetCount,
|
||||
budgetAtRisk: projectBudgetAtRisk,
|
||||
financialVariance: variance,
|
||||
varianceType,
|
||||
maxScore,
|
||||
};
|
||||
})
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER')
|
||||
.sort((a, b) => b.maxScore - a.maxScore)
|
||||
.slice(0, 5);
|
||||
}, [projects, filteredProjects, risks]);
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER' || !p.isCompliant)
|
||||
.sort((a, b) => b.budgetAtRisk - a.budgetAtRisk || b.maxScore - a.maxScore)
|
||||
.slice(0, 8);
|
||||
}, [projects, filteredProjects, risks, projectComplianceMap, budgets]);
|
||||
|
||||
const {
|
||||
sortedData: sortedAttentionProjects,
|
||||
@@ -498,13 +573,31 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6 Headline Portfolio KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* Executive Portfolio KPIs: Structured 2-Tier Balanced Grid */}
|
||||
<div className="space-y-6">
|
||||
{/* Tier 1: Capital Commitments & Deployment Scale */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Capital Deployment & Scale
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Portfolio commitments, disbursements & capital exposure</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-blue-400/90 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
|
||||
Financial Governance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Portfolio Value */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Portfolio Value
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Total Portfolio Value"
|
||||
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
||||
@@ -512,45 +605,32 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
|
||||
<Coins className="w-3.5 h-3.5" />
|
||||
<div className="shrink-0 p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-105 transition-transform">
|
||||
<Coins className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
|
||||
<HelpTooltip
|
||||
title="Active Grant Awards"
|
||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
|
||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">
|
||||
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{formatCompactZAR(portfolioValue)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(portfolioValue)}>
|
||||
{formatZAR(portfolioValue)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 shrink-0">
|
||||
Statutory Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
|
||||
{/* Card 2: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Disbursed
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Disbursed Funds"
|
||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||
@@ -558,23 +638,125 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<div className="shrink-0 p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-105 transition-transform">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">
|
||||
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-teal-400 tracking-tight">
|
||||
{formatCompactZAR(disbursed)}
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-full border border-teal-500/20 shrink-0 font-mono">
|
||||
{disbursedPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${disbursedPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Tranches released</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">{disbursedPercentage}% deployed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
|
||||
{/* Card 3: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Active Awards
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Active Grant Awards"
|
||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-105 transition-transform">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{activeAwards}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Under implementation</span>
|
||||
<span className="font-medium text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 shrink-0 text-[10px]">
|
||||
{portfolios.length} Portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Budget At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Budget At Risk
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Total Budgeted Capital At Risk"
|
||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-rose-400 tracking-tight">
|
||||
{formatCompactZAR(totalBudgetAtRisk)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(totalBudgetAtRisk)}>
|
||||
{formatZAR(totalBudgetAtRisk)}
|
||||
</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
totalBudgetAtRisk > 0
|
||||
? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{totalBudgetAtRisk > 0 ? 'Exposed' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier 2: Operational Health, Risk & Beneficiary Impact */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Operational Performance & Impact
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Milestone pace, compliance gate health & direct beneficiaries</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-emerald-400/90 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||
Delivery Health
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 5: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
On Track
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="On-Track Progress Ratio"
|
||||
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
||||
@@ -582,21 +764,79 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{onTrackPercentage}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{onTrackPercentage >= 80 ? 'Optimal' : onTrackPercentage >= 60 ? 'Moderate' : 'Critical'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, onTrackPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Milestones verified</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">Pace index</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
|
||||
{/* Card 6: Compliance Health */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Compliance
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Portfolio Compliance Health"
|
||||
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{complianceRate}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{complianceRate >= 90 ? 'Passed' : 'Gated'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, complianceRate)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Statutory gate check</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">100% requirement</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 7: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
At Risk
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="At-Risk Projects"
|
||||
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
||||
@@ -604,21 +844,34 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<div className="shrink-0 p-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-105 transition-transform">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
|
||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-amber-400 tracking-tight">
|
||||
{atRiskCount}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">{atRiskCount === 1 ? 'Project flagged' : 'Projects flagged'}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
atRiskCount > 0
|
||||
? 'text-amber-400 bg-amber-500/10 border-amber-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{atRiskCount > 0 ? 'Requires PM Action' : 'Nominal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
||||
{/* Card 8: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Beneficiaries
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Beneficiaries Impacted"
|
||||
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
||||
@@ -626,15 +879,22 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<div className="shrink-0 p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20 group-hover:scale-105 transition-transform">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(1)}k` : beneficiariesReached}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Directly impacted</span>
|
||||
<span className="text-[10px] font-medium text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded border border-purple-500/20 shrink-0">
|
||||
M&E Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -774,7 +1034,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
|
||||
<h3 className="text-base font-semibold text-white">Projects Flagged At Risk & Noncompliant</h3>
|
||||
<HelpTooltip
|
||||
title="Exception Escalations"
|
||||
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
|
||||
@@ -807,11 +1067,14 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Status
|
||||
</SortableTh>
|
||||
<SortableTh column="complianceStatus" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Compliance
|
||||
</SortableTh>
|
||||
<SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center">
|
||||
Risk
|
||||
</SortableTh>
|
||||
<SortableTh column="financialVariance" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Financial Variance
|
||||
<SortableTh column="budgetAtRisk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Budget At Risk
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
@@ -819,7 +1082,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{sortedAttentionProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-xs text-slate-500">
|
||||
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -844,6 +1107,19 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{p.isCompliant ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span>Compliant</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Noncompliant ({p.unmetCount} Unmet)</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
|
||||
@@ -857,13 +1133,9 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.risk}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<span
|
||||
className={`font-mono font-bold text-xs ${
|
||||
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{p.financialVariance}
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-xs">
|
||||
<span className={p.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{formatZAR(p.budgetAtRisk)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Edit2,
|
||||
Mail,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
ChevronRight,
|
||||
Search,
|
||||
@@ -24,9 +25,11 @@ import {
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -60,6 +63,7 @@ import {
|
||||
assignUserRole,
|
||||
getSystemRoles,
|
||||
getCurrentUserTenantId,
|
||||
notifyNewUserCreated,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -68,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||
import { RulesSection } from '../components/governance/RulesSection';
|
||||
@@ -109,6 +114,7 @@ const organisationSchema = z.object({
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||
idNumber: z.string().optional(),
|
||||
type: z.coerce.number().min(1).max(27),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -133,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
||||
|
||||
export const GovernancePage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { sub, user, claims } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const { sub, user, claims, roles, tenantId } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
|
||||
const currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -240,7 +246,6 @@ export const GovernancePage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||
@@ -251,7 +256,7 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const assignableRolesForOrg = React.useMemo(() => {
|
||||
return systemRoles.filter(
|
||||
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
|
||||
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
|
||||
);
|
||||
}, [systemRoles]);
|
||||
|
||||
@@ -262,6 +267,37 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
}, [usersDirectory]);
|
||||
|
||||
const modalExecutiveUsers = React.useMemo(() => {
|
||||
const list: User[] = [...executiveAndManagerUsers];
|
||||
const linkedUserId =
|
||||
editingOrg?.userId ||
|
||||
editingOrg?.UserId ||
|
||||
'';
|
||||
|
||||
if (linkedUserId) {
|
||||
const linkedStr = String(linkedUserId).toLowerCase();
|
||||
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (!alreadyIncluded) {
|
||||
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (inDirectory) {
|
||||
list.unshift(inDirectory);
|
||||
} else {
|
||||
const email = getUserEmail(linkedUserId);
|
||||
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
|
||||
list.unshift({
|
||||
id: String(linkedUserId),
|
||||
email: hasRealEmail ? email : String(linkedUserId),
|
||||
role: 'Executive',
|
||||
status: ActivityStatus.Active,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
|
||||
|
||||
// Quick Add Executive User Subform state
|
||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||
@@ -274,9 +310,9 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id);
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id);
|
||||
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
|
||||
}
|
||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||
|
||||
@@ -334,6 +370,19 @@ export const GovernancePage: React.FC = () => {
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
}
|
||||
|
||||
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
|
||||
if (createdUser) {
|
||||
await notifyNewUserCreated({
|
||||
userId: createdUser.id,
|
||||
email: quickUserEmail,
|
||||
isPartner: false,
|
||||
roleName: 'Executive',
|
||||
tenantId,
|
||||
}).catch(console.warn);
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||
setQuickUserEmail('');
|
||||
setQuickUserName('');
|
||||
@@ -546,11 +595,23 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: '',
|
||||
idNumber: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
status: ActivityStatus.Inactive,
|
||||
},
|
||||
});
|
||||
|
||||
const watchedOrgUser = orgForm.watch('organisationUser');
|
||||
const selectedExecutiveUser = React.useMemo(() => {
|
||||
if (!watchedOrgUser) return null;
|
||||
const str = String(watchedOrgUser).toLowerCase();
|
||||
return (
|
||||
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
null
|
||||
);
|
||||
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
|
||||
|
||||
const portfolioForm = useForm<PortfolioFormValues>({
|
||||
resolver: zodResolver(portfolioSchema),
|
||||
defaultValues: {
|
||||
@@ -566,7 +627,7 @@ export const GovernancePage: React.FC = () => {
|
||||
defaultValues: {
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
},
|
||||
});
|
||||
@@ -575,11 +636,20 @@ export const GovernancePage: React.FC = () => {
|
||||
const orgMutation = useMutation({
|
||||
mutationFn: (data: OrganisationFormValues) => {
|
||||
if (editingOrg) {
|
||||
if (!data.idNumber || !data.idNumber.trim()) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Government ID number is required to assign this executive user.',
|
||||
});
|
||||
throw new Error('Executive user ID number is required when updating an organisation.');
|
||||
}
|
||||
|
||||
return updateOrganisation({
|
||||
organisationId: editingOrg.id,
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber.trim(),
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||
@@ -589,11 +659,13 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber?.trim() || null,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||
setIsOrgModalOpen(false);
|
||||
@@ -625,6 +697,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||
setIsPortfolioModalOpen(false);
|
||||
@@ -636,15 +709,43 @@ export const GovernancePage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const approveProgrammeMutation = useMutation({
|
||||
mutationFn: async (pr: Programme) => {
|
||||
if (isMaker(pr.createdBy)) {
|
||||
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
|
||||
}
|
||||
return updateProgramme({
|
||||
programmeId: pr.id,
|
||||
name: pr.name || '',
|
||||
portfolioId: Number(pr.portfolioId),
|
||||
status: ApprovalStatus.Approved,
|
||||
ownerId: pr.ownedBy,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Programme Approval Failed', err);
|
||||
},
|
||||
});
|
||||
|
||||
const programmeMutation = useMutation({
|
||||
mutationFn: (data: ProgrammeFormValues) => {
|
||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||
if (editingProgramme) {
|
||||
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||
}
|
||||
return updateProgramme({
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
||||
status: targetStatus,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -655,7 +756,9 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||
setIsProgrammeModalOpen(false);
|
||||
setEditingProgramme(null);
|
||||
@@ -674,8 +777,9 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: defaultUser?.id || '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
status: ActivityStatus.Inactive,
|
||||
});
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
@@ -688,26 +792,51 @@ export const GovernancePage: React.FC = () => {
|
||||
const targetUserId =
|
||||
org.userId ||
|
||||
org.UserId ||
|
||||
org.organisationUser ||
|
||||
org.OrganisationUser ||
|
||||
org.createdBy ||
|
||||
'';
|
||||
|
||||
const matchedUser = targetUserId
|
||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
||||
: null;
|
||||
let selectedUserId = '';
|
||||
let matchedUser: User | undefined;
|
||||
if (targetUserId) {
|
||||
const targetStr = String(targetUserId).toLowerCase();
|
||||
matchedUser = usersDirectory.find(
|
||||
(u) =>
|
||||
(u.id && String(u.id).toLowerCase() === targetStr) ||
|
||||
(u.email && u.email.toLowerCase() === targetStr)
|
||||
);
|
||||
|
||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
||||
if (matchedUser) {
|
||||
selectedUserId = matchedUser.id;
|
||||
if (matchedUser.email) {
|
||||
registerUserEmail(matchedUser.id, matchedUser.email);
|
||||
}
|
||||
} else {
|
||||
selectedUserId = String(targetUserId);
|
||||
const email = getUserEmail(targetUserId);
|
||||
if (email && email !== '—' && email.includes('@')) {
|
||||
registerUserEmail(selectedUserId, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
|
||||
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
organisationUser: selectedUserId,
|
||||
idNumber: resolvedIdNumber,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
|
||||
if (selectedUserId && !resolvedIdNumber) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
|
||||
});
|
||||
}
|
||||
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
@@ -741,7 +870,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeForm.reset({
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
});
|
||||
setIsProgrammeModalOpen(true);
|
||||
@@ -819,6 +948,7 @@ export const GovernancePage: React.FC = () => {
|
||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||
createdAt: (p) => p.createdAt,
|
||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||
createdBy: (p) => getUserEmail(p.createdBy),
|
||||
});
|
||||
|
||||
// Export Columns Definitions
|
||||
@@ -849,6 +979,7 @@ export const GovernancePage: React.FC = () => {
|
||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||
},
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||
];
|
||||
@@ -861,12 +992,13 @@ export const GovernancePage: React.FC = () => {
|
||||
key: 'portfolioId',
|
||||
format: (val) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(val));
|
||||
return port ? port.name : `Portfolio #${val}`;
|
||||
return port && port.name ? port.name : `Portfolio #${val}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1136,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{org.email}
|
||||
</span>
|
||||
{org.userId && (
|
||||
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
|
||||
Exec: {getUserEmail(org.userId)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
@@ -1253,8 +1390,8 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
||||
{org ? org.name : `Org #${p.organisationId}`}
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
|
||||
{org && org.name ? org.name : `Org #${p.organisationId}`}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||
</div>
|
||||
@@ -1368,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
|
||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Owner Email
|
||||
</SortableTh>
|
||||
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Created By
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{loadingProgrammes ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading programmes...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedProgrammes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
No programmes found matching query.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1388,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
|
||||
sortedProgrammes.map((pr) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||
return (
|
||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
@@ -1406,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
|
||||
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
||||
{getUserEmail(pr.ownedBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{getUserEmail(pr.createdBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||
{isApproved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Edit Programme"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
{canApproveRecord('governance', pr.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Approve Programme"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1444,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<Scale className="w-3 h-3" />
|
||||
Rules
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1597,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked Executive / Manager User *
|
||||
Responsible Executive User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
||||
title="Executive / Organisation User"
|
||||
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
@@ -1622,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser')}
|
||||
{...orgForm.register('organisationUser', {
|
||||
onChange: (e) => {
|
||||
const selectedId = e.target.value;
|
||||
if (!selectedId) {
|
||||
orgForm.setValue('idNumber', '');
|
||||
return;
|
||||
}
|
||||
const str = String(selectedId).toLowerCase();
|
||||
const matched =
|
||||
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
|
||||
|
||||
if (matched?.idNumber && matched.idNumber.trim()) {
|
||||
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
|
||||
orgForm.clearErrors('idNumber');
|
||||
} else {
|
||||
orgForm.setValue('idNumber', '', { shouldValidate: true });
|
||||
if (editingOrg) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
})}
|
||||
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"
|
||||
>
|
||||
<option value="">-- Select linked executive or manager account --</option>
|
||||
{executiveAndManagerUsers.map((u) => (
|
||||
{modalExecutiveUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} ({u.role || 'Executive'})
|
||||
</option>
|
||||
@@ -1640,7 +1839,59 @@ export const GovernancePage: React.FC = () => {
|
||||
No Executive or Manager user found. Create one below to continue.
|
||||
</p>
|
||||
)}
|
||||
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing ID Number Alert */}
|
||||
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-amber-200">
|
||||
Missing Executive ID Number
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Executive User ID Number Input */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Executive ID Requirement"
|
||||
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedExecutiveUser?.idNumber && (
|
||||
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
{...orgForm.register('idNumber')}
|
||||
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||
}`}
|
||||
placeholder="Enter 13-digit ID or Passport Number"
|
||||
/>
|
||||
{orgForm.formState.errors.idNumber && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Collapsible Subform to Create Executive User */}
|
||||
@@ -1990,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
||||
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||
title="Approved programmes are immutable."
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
@@ -2446,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
|
||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
|
||||
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
|
||||
<span className="text-slate-300 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2703,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||
</span>
|
||||
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={async () => {
|
||||
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||
setIsProgrammeDetailModalOpen(false);
|
||||
setSelectedProgrammeForDetail(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<span>Approve Programme</span>
|
||||
</button>
|
||||
) : (
|
||||
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Details Grid: Owner & Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
{/* Creation Date */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
@@ -2731,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maker */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics: Total Rules and Total Instructions */}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import {
|
||||
Award,
|
||||
Budget,
|
||||
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
|
||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||
|
||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateAwardMutation = useMutation({
|
||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Award Updated', 'Award details saved successfully.');
|
||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget line established', 'Budget line created against project');
|
||||
setIsBudgetModalOpen(false);
|
||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateBudgetMutation = useMutation({
|
||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||
setIsEditBudgetModalOpen(false);
|
||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
success(
|
||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success(
|
||||
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleAwardSort,
|
||||
} = useTableSort(filteredAwards, null, null, {
|
||||
referenceNo: (a) => a.referenceNo,
|
||||
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
|
||||
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
|
||||
organisationId: (a) => {
|
||||
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
|
||||
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
|
||||
},
|
||||
programmeId: (a) => {
|
||||
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
|
||||
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
|
||||
},
|
||||
amount: (a) => a.amount,
|
||||
status: (a) => getApprovalStatusBadge(a.status).label,
|
||||
createdAt: (a) => a.createdAt,
|
||||
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleBudgetSort,
|
||||
} = useTableSort(filteredBudgets, null, null, {
|
||||
name: (b) => b.name,
|
||||
projectId: (b) => b.projectId,
|
||||
projectId: (b) => {
|
||||
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
|
||||
},
|
||||
amount: (b) => b.amount,
|
||||
createdAt: (b) => b.createdAt,
|
||||
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
||||
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
|
||||
handleSort: handleDisbSort,
|
||||
} = useTableSort(filteredDisbursements, null, null, {
|
||||
id: (d) => d.id,
|
||||
projectId: (d) => d.projectId,
|
||||
milestoneId: (d) => d.milestoneId || 0,
|
||||
projectId: (d) => {
|
||||
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
|
||||
},
|
||||
milestoneId: (d) => {
|
||||
if (!d.milestoneId) return 'Advance Tranche';
|
||||
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
|
||||
},
|
||||
amount: (d) => d.amount,
|
||||
paymentReference: (d) => d.paymentReference || '',
|
||||
status: (d) => getApprovalStatusBadge(d.status).label,
|
||||
@@ -826,8 +848,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchAwards();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
await refetchAwards();
|
||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingAwards}
|
||||
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
|
||||
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
||||
{awd.organisationName || `Org #${awd.organisationId}`}
|
||||
{(() => {
|
||||
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
|
||||
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
|
||||
return (
|
||||
<div>
|
||||
<span className="text-white block font-medium">{orgName}</span>
|
||||
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400">
|
||||
{awd.programmeName || `Programme #${awd.programmeId}`}
|
||||
{(() => {
|
||||
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
|
||||
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
|
||||
return (
|
||||
<div>
|
||||
<span className="text-slate-200 block">{progName}</span>
|
||||
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||
{formatZAR(awd.amount)}
|
||||
@@ -919,10 +961,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved award is immutable. Details cannot be modified."
|
||||
title="Approved award details cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
@@ -973,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchBudgets();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
await refetchBudgets();
|
||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingBudgets}
|
||||
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
|
||||
Budget Line Category
|
||||
</SortableTh>
|
||||
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||
Project ID
|
||||
Project
|
||||
</SortableTh>
|
||||
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Approved Amount (ZAR)
|
||||
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
|
||||
<span className="font-semibold text-white block">{b.name}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-200">
|
||||
{(() => {
|
||||
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||
const projName = proj?.name || `Project #${b.projectId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-white block">{projName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||
{formatZAR(b.amount)}
|
||||
</td>
|
||||
@@ -1059,10 +1114,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved budget is immutable. Status cannot be modified."
|
||||
title="Approved budget status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1133,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchDisbursements();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
await refetchDisbursements();
|
||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingDisbursements}
|
||||
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
|
||||
Disbursement ID
|
||||
</SortableTh>
|
||||
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||
Project ID
|
||||
Project
|
||||
</SortableTh>
|
||||
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||
Milestone Association
|
||||
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
|
||||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||||
DSB-{d.id}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-200">
|
||||
{(() => {
|
||||
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||
const projName = proj?.name || `Project #${d.projectId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-white block">{projName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
||||
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
|
||||
{(() => {
|
||||
if (!d.milestoneId) {
|
||||
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
|
||||
}
|
||||
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||
const msName = ms?.name || `Milestone #${d.milestoneId}`;
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium block">{msName}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||
{formatZAR(d.amount)}
|
||||
@@ -1549,7 +1629,14 @@ export const GrantsPage: React.FC = () => {
|
||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||
if (!editingAward) return;
|
||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
||||
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedAwardStatus === ApprovalStatus.Approved &&
|
||||
!canApproveRecord('grant_financials', editingAward.createdBy)
|
||||
) {
|
||||
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
|
||||
return;
|
||||
}
|
||||
updateAwardMutation.mutate({
|
||||
@@ -1630,7 +1717,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||
<option value={ApprovalStatus.Approved}>Approved & Activated</option>
|
||||
{(() => {
|
||||
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
|
||||
const isSelf = isMaker(editingAward?.createdBy);
|
||||
let reason = '';
|
||||
if (!canApprove('grant_financials')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveAwd}>
|
||||
Approved & Activated {reason}
|
||||
</option>
|
||||
);
|
||||
})()}
|
||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||
</select>
|
||||
)}
|
||||
@@ -1783,7 +1884,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
FileText,
|
||||
Star,
|
||||
Trash2,
|
||||
Mail,
|
||||
MailOpen,
|
||||
Search,
|
||||
PenSquare,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
X,
|
||||
Smartphone,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
||||
import { formatDate } from '../lib/formatters';
|
||||
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { stripMarkdown } from '../lib/markdownUtils';
|
||||
import { getUsers } from '../services/api';
|
||||
|
||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||
|
||||
export const InboxPage: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const targetIdParam = searchParams.get('id');
|
||||
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
|
||||
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
});
|
||||
|
||||
const {
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadCount,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications,
|
||||
drafts,
|
||||
starredIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
deleteDraft,
|
||||
sendMessage,
|
||||
isSending,
|
||||
} = useInAppNotifications();
|
||||
|
||||
// Active folder
|
||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||
// Search query
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// Filter for unread only in Inbox
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
// Selected message to view in detail
|
||||
const [selectedMessage, setSelectedMessage] = useState<Notification | null>(null);
|
||||
// Selected draft to edit/resume
|
||||
const [selectedDraft, setSelectedDraft] = useState<DraftMessage | null>(null);
|
||||
|
||||
// Compose modal state
|
||||
const [isComposeOpen, setIsComposeOpen] = useState(false);
|
||||
const [composeInitialRecipient, setComposeInitialRecipient] = useState<any>(undefined);
|
||||
const [composeInitialSubject, setComposeInitialSubject] = useState('');
|
||||
const [composeInitialMessage, setComposeInitialMessage] = useState('');
|
||||
const [composeInitialPlatform, setComposeInitialPlatform] = useState<NotificationPlatform>(NotificationPlatform.InApp);
|
||||
|
||||
// Inline quick reply state
|
||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||
// Self-reply prevention modal state
|
||||
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
|
||||
|
||||
// Active folder message items (always In-App notifications)
|
||||
const folderMessages = useMemo(() => {
|
||||
switch (activeFolder) {
|
||||
case 'inbox':
|
||||
return inboxNotifications;
|
||||
case 'starred':
|
||||
return starredNotifications;
|
||||
case 'sent':
|
||||
return sentNotifications;
|
||||
case 'trash':
|
||||
return trashedNotifications;
|
||||
case 'drafts':
|
||||
return [];
|
||||
default:
|
||||
return inboxNotifications;
|
||||
}
|
||||
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||
|
||||
// Filtered by search and unread toggle
|
||||
const filteredMessages = useMemo(() => {
|
||||
return folderMessages.filter(item => {
|
||||
// Unread only filter
|
||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||
}
|
||||
// Search term
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
const subject = (item.subject || '').toLowerCase();
|
||||
const message = (item.message || '').toLowerCase();
|
||||
const dest = (item.destination || '').toLowerCase();
|
||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||
});
|
||||
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
||||
|
||||
// Filtered drafts for Drafts folder
|
||||
const filteredDrafts = useMemo(() => {
|
||||
if (activeFolder !== 'drafts') return [];
|
||||
if (!searchTerm.trim()) return drafts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return drafts.filter(
|
||||
d =>
|
||||
d.subject.toLowerCase().includes(term) ||
|
||||
d.message.toLowerCase().includes(term) ||
|
||||
d.recipientEmail.toLowerCase().includes(term)
|
||||
);
|
||||
}, [drafts, activeFolder, searchTerm]);
|
||||
|
||||
// Automatically select message when URL param ?id=... is present
|
||||
useEffect(() => {
|
||||
if (targetIdParam) {
|
||||
const found = folderMessages.find(m => String(m.id) === targetIdParam);
|
||||
if (found) {
|
||||
setSelectedMessage(found);
|
||||
if (Number(found.status) !== NotificationStatus.Read) {
|
||||
markAsRead(found.id);
|
||||
}
|
||||
}
|
||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||
const first = folderMessages[0];
|
||||
setSelectedMessage(first);
|
||||
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
|
||||
markAsRead(first.id);
|
||||
}
|
||||
}
|
||||
}, [targetIdParam, folderMessages, activeFolder]);
|
||||
|
||||
// Handle message selection
|
||||
const handleSelectMessage = (item: Notification) => {
|
||||
setSelectedMessage(item);
|
||||
setSearchParams({ id: String(item.id) });
|
||||
if (Number(item.status) !== NotificationStatus.Read) {
|
||||
markAsRead(item.id);
|
||||
}
|
||||
setInlineReplyText('');
|
||||
setInlineReplySuccess(false);
|
||||
};
|
||||
|
||||
// Open Compose Modal for a new message
|
||||
const handleOpenCompose = (defaultPlatform: NotificationPlatform = NotificationPlatform.InApp) => {
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(undefined);
|
||||
setComposeInitialSubject('');
|
||||
setComposeInitialMessage('');
|
||||
setComposeInitialPlatform(defaultPlatform);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
const extractSenderEmail = (msg: Notification): string => {
|
||||
const rawDest = msg.destination || '';
|
||||
if (rawDest.includes('From:')) {
|
||||
const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || '';
|
||||
return fromPart.replace(/[<>]/g, '').trim();
|
||||
}
|
||||
if (!rawDest.includes('To:') && rawDest.includes('@')) {
|
||||
return rawDest.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isNotificationFromSelf = (msg: Notification): boolean => {
|
||||
if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) {
|
||||
return true;
|
||||
}
|
||||
const senderEmail = extractSenderEmail(msg).toLowerCase();
|
||||
const myEmail = currentUserEmail.trim().toLowerCase();
|
||||
if (myEmail && senderEmail && senderEmail === myEmail) {
|
||||
return true;
|
||||
}
|
||||
if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Open Compose Modal for a Reply
|
||||
const handleReplyMessage = (msg: Notification) => {
|
||||
if (isNotificationFromSelf(msg)) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyEmail = extractSenderEmail(msg);
|
||||
const matchedSender = replyEmail
|
||||
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
(matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) ||
|
||||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
||||
) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
|
||||
const quotedBody = `\n\n--- Original Message (${formatDate(msg.createdAt)}) ---\n> ${stripMarkdown(msg.message || '')}`;
|
||||
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(
|
||||
replyEmail && matchedSender
|
||||
? {
|
||||
id: String(matchedSender.id),
|
||||
email: replyEmail,
|
||||
name: replyEmail.split('@')[0],
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(replySubject);
|
||||
setComposeInitialMessage(quotedBody);
|
||||
setComposeInitialPlatform(msg.platform as NotificationPlatform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Resume editing a saved draft
|
||||
const handleEditDraft = (draft: DraftMessage) => {
|
||||
setSelectedDraft(draft);
|
||||
setComposeInitialRecipient(
|
||||
draft.recipientId
|
||||
? {
|
||||
id: draft.recipientId,
|
||||
email: draft.recipientEmail,
|
||||
name: draft.recipientName,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(draft.subject);
|
||||
setComposeInitialMessage(draft.message);
|
||||
setComposeInitialPlatform(draft.platform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Send inline quick reply
|
||||
const handleSendInlineReply = async () => {
|
||||
if (!selectedMessage || !inlineReplyText.trim()) return;
|
||||
|
||||
if (isNotificationFromSelf(selectedMessage)) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyEmail = extractSenderEmail(selectedMessage);
|
||||
const matchedSender = replyEmail
|
||||
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient);
|
||||
if (
|
||||
targetRecipientId.trim().toLowerCase() === currentUserId ||
|
||||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
|
||||
) {
|
||||
setIsSelfReplyModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
recipientId: targetRecipientId,
|
||||
recipientEmail: replyEmail || 'team@dsi.gov.za',
|
||||
subject: selectedMessage.subject?.startsWith('Re:')
|
||||
? selectedMessage.subject
|
||||
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
||||
message: inlineReplyText.trim(),
|
||||
platform: selectedMessage.platform as NotificationPlatform || NotificationPlatform.InApp,
|
||||
});
|
||||
|
||||
setInlineReplySuccess(true);
|
||||
setInlineReplyText('');
|
||||
setTimeout(() => {
|
||||
setInlineReplySuccess(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to send inline reply:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-[#090d16] text-slate-100 overflow-hidden font-sans">
|
||||
{/* Top Mailbox Breadcrumb & Action Banner */}
|
||||
<div className="h-14 px-6 border-b border-slate-800/80 bg-[#0d121f]/90 backdrop-blur-md flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
In-App Messaging
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
In-App notifications and internal platform communications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-white transition cursor-pointer shadow-xs"
|
||||
title="Refresh mailbox messages"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
id="compose-message-main-btn"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5" />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Mailbox Three-Pane Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 1: Mailbox Folders Sidebar */}
|
||||
{/* ======================================================== */}
|
||||
<aside className="w-60 shrink-0 border-r border-slate-800/80 bg-[#0b101b]/95 p-3 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Primary Compose Action */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-bold shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>New Message</span>
|
||||
</button>
|
||||
|
||||
{/* Folder Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{/* Inbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('inbox');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'inbox'
|
||||
? 'bg-blue-600/20 text-white border border-blue-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Inbox className="w-4 h-4 text-blue-400" />
|
||||
<span>Inbox</span>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="text-[10px] font-bold text-white bg-blue-600 px-2 py-0.5 rounded-full shadow-xs">
|
||||
{unreadCount}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{allMailboxNotifications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Starred */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('starred');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'starred'
|
||||
? 'bg-amber-500/20 text-white border border-amber-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>Starred</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{starredNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('sent');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'sent'
|
||||
? 'bg-indigo-600/20 text-white border border-indigo-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Send className="w-4 h-4 text-indigo-400" />
|
||||
<span>Sent</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{sentNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Drafts */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('drafts');
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'drafts'
|
||||
? 'bg-purple-600/20 text-white border border-purple-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span>Drafts</span>
|
||||
</div>
|
||||
{drafts.length > 0 ? (
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
||||
{drafts.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Trash */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('trash');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'trash'
|
||||
? 'bg-rose-600/20 text-white border border-rose-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Trash2 className="w-4 h-4 text-rose-400" />
|
||||
<span>Trash</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{trashedNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Quick Filter: Unread Toggle */}
|
||||
{activeFolder === 'inbox' && (
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
<label className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl hover:bg-slate-800/50 cursor-pointer text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnreadOnly}
|
||||
onChange={e => setShowUnreadOnly(e.target.checked)}
|
||||
className="rounded border-slate-700 bg-slate-900 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Unread only</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer Branding */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>In-App Notifications</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 2: Message List / Summary Cards */}
|
||||
{/* ======================================================== */}
|
||||
<section className="w-80 sm:w-96 shrink-0 border-r border-slate-800/80 bg-[#0d121f]/60 flex flex-col overflow-hidden">
|
||||
{/* Search Box Header */}
|
||||
<div className="p-3 border-b border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder="Search messages by subject, sender, or text..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-xl bg-slate-900 border border-slate-700/80 text-xs text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||
<span className="capitalize font-semibold text-slate-200">
|
||||
{activeFolder} ({activeFolder === 'drafts' ? filteredDrafts.length : filteredMessages.length})
|
||||
</span>
|
||||
{activeFolder === 'inbox' && unreadCount > 0 && (
|
||||
<span className="text-blue-400 font-medium">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of Messages or Drafts */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{activeFolder === 'drafts' ? (
|
||||
filteredDrafts.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<FileText className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Drafts Saved</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
When you save unfinished messages, they will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDrafts.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => handleEditDraft(d)}
|
||||
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex flex-col gap-1.5 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
||||
Draft
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(d.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-semibold text-white group-hover:text-purple-300 transition truncate">
|
||||
{d.subject || 'Untitled Draft'}
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(d.message || 'No content written yet.')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[10px] text-slate-400 truncate">
|
||||
To: {d.recipientEmail || 'No recipient selected'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteDraft(d.id);
|
||||
}}
|
||||
className="text-slate-400 hover:text-rose-400 p-1 rounded transition"
|
||||
title="Delete draft"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<MailOpen className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Messages Found</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
{searchTerm
|
||||
? `No messages matched "${searchTerm}"`
|
||||
: `No items in ${activeFolder}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map(item => {
|
||||
const isSelected = selectedMessage?.id === item.id;
|
||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||
const isStarred = starredIds.includes(String(item.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelectMessage(item)}
|
||||
className={`p-3.5 transition cursor-pointer flex flex-col gap-1 relative ${
|
||||
isSelected
|
||||
? 'bg-blue-600/15 border-l-4 border-l-blue-500 text-white shadow-inner'
|
||||
: 'hover:bg-slate-800/50 border-l-4 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Top line: unread dot, platform badge, star, time */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isUnread && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-blue-500 shrink-0 shadow-sm shadow-blue-500 animate-pulse"
|
||||
title="Unread notification"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-slate-300 truncate">
|
||||
{item.destination?.includes('From:')
|
||||
? item.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: item.destination || 'PFM System'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
In-App
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleStar(item.id);
|
||||
}}
|
||||
className={`p-0.5 rounded transition ${
|
||||
isStarred ? 'text-amber-400 hover:text-amber-300' : 'text-slate-400 hover:text-slate-300'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar message' : 'Star message'}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteMessage(item.id);
|
||||
if (selectedMessage?.id === item.id) {
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
}}
|
||||
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
|
||||
title="Delete message (Archive)"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<h4
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-bold text-white' : 'font-medium text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{item.subject || 'Statutory Platform Notice'}
|
||||
</h4>
|
||||
|
||||
{/* Snippet preview stripped of markdown syntax */}
|
||||
<p className="text-[11px] text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional details provided.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 3: Full Message Detail & Interactive Actions */}
|
||||
{/* ======================================================== */}
|
||||
<main className="flex-1 bg-[#090d16] flex flex-col overflow-y-auto">
|
||||
{activeFolder === 'drafts' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
||||
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||
Click any draft on the left pane to resume composing and dispatch your notification.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>Create New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : !selectedMessage ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<div className="w-16 h-16 rounded-3xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4 shadow-xl">
|
||||
<Mail className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
||||
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-700 text-xs font-semibold transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Compose New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Message Header Toolbar */}
|
||||
<div className="p-5 border-b border-slate-800/80 bg-[#0d121f]/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
title="Reply to message"
|
||||
>
|
||||
<Reply className="w-3.5 h-3.5" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStar(selectedMessage.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-medium transition cursor-pointer ${
|
||||
starredIds.includes(String(selectedMessage.id))
|
||||
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
|
||||
: 'bg-slate-900/80 border-slate-700/80 text-slate-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-3.5 h-3.5 ${
|
||||
starredIds.includes(String(selectedMessage.id)) ? 'fill-amber-400' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>{starredIds.includes(String(selectedMessage.id)) ? 'Starred' : 'Star'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeFolder === 'trash' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restoreMessage(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700 text-xs text-slate-200 transition cursor-pointer"
|
||||
title="Restore message to Inbox"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => permanentDelete(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-950/40 hover:bg-rose-900/40 border border-rose-800/60 text-xs text-rose-300 transition cursor-pointer"
|
||||
title="Permanently erase message"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Delete Forever</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deleteMessage(selectedMessage.id);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 border border-transparent hover:border-rose-900/40 transition cursor-pointer"
|
||||
title="Move message to Trash"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Details & Body */}
|
||||
<div className="p-6 space-y-6 flex-1 overflow-y-auto max-w-4xl">
|
||||
{/* Subject Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight leading-tight">
|
||||
{selectedMessage.subject || 'In-App Notification'}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||
Channel: In-App
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||
ID: #{selectedMessage.id}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Received: {formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender & Recipient Card */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-xs text-white shadow-md shadow-blue-500/20 shrink-0">
|
||||
{selectedMessage.destination?.includes('@')
|
||||
? selectedMessage.destination.split('@')[0].slice(0, 2).toUpperCase()
|
||||
: 'PF'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-white">
|
||||
{selectedMessage.destination?.includes('From:')
|
||||
? selectedMessage.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
(In-App Notification)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
To:{' '}
|
||||
<strong className="text-slate-300">
|
||||
{selectedMessage.destination?.includes('To:')
|
||||
? selectedMessage.destination.split('To:')[1]?.split('|')[0]?.trim()
|
||||
: currentUserEmail || 'You'}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fully-Rendered Markdown & HTML Message Body with Branding */}
|
||||
<MarkdownViewer
|
||||
content={selectedMessage.message || ''}
|
||||
subject={selectedMessage.subject || ''}
|
||||
showBrandingHeader={true}
|
||||
/>
|
||||
|
||||
{/* Inline Quick Reply Section */}
|
||||
<div className="pt-6 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Quick Reply</span>
|
||||
</h4>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Send a quick response below or open the rich text composer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{inlineReplySuccess && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Reply dispatched successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
value={inlineReplyText}
|
||||
onChange={e => setInlineReplyText(e.target.value)}
|
||||
placeholder="Type your reply..."
|
||||
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 font-medium transition cursor-pointer"
|
||||
>
|
||||
Open rich compose editor with live preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSending || !inlineReplyText.trim()}
|
||||
onClick={handleSendInlineReply}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/30 transition disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Send Reply</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Compose Message Modal */}
|
||||
<ComposeMessageModal
|
||||
isOpen={isComposeOpen}
|
||||
onClose={() => setIsComposeOpen(false)}
|
||||
initialRecipient={composeInitialRecipient}
|
||||
initialSubject={composeInitialSubject}
|
||||
initialMessage={composeInitialMessage}
|
||||
initialPlatform={composeInitialPlatform}
|
||||
draftToEdit={selectedDraft}
|
||||
/>
|
||||
|
||||
{/* Self-Reply Prevention Modal */}
|
||||
{isSelfReplyModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
|
||||
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelfReplyModalOpen(false)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague.
|
||||
</p>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelfReplyModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
Understood
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Filter,
|
||||
FileText,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
createIndicator,
|
||||
recordIndicatorActual,
|
||||
getBudgets,
|
||||
getActivities,
|
||||
getDisbursements,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -73,6 +76,7 @@ import {
|
||||
getUserEmail,
|
||||
} from '../lib/formatters';
|
||||
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
|
||||
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
|
||||
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
|
||||
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
|
||||
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
|
||||
@@ -192,9 +196,12 @@ export const ProjectsPage: React.FC = () => {
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
|
||||
const [activeModalProject, setActiveModalProject] = useState<Project | null>(null);
|
||||
const [selectedProjectForCompliance, setSelectedProjectForCompliance] = useState<Project | null>(null);
|
||||
const [isComplianceModalOpen, setIsComplianceModalOpen] = useState(false);
|
||||
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
|
||||
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
|
||||
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
|
||||
const [initialIndicatorActivityId, setInitialIndicatorActivityId] = useState<string | number | null>(null);
|
||||
const [isRisksModalOpen, setIsRisksModalOpen] = useState(false);
|
||||
|
||||
// Queries
|
||||
@@ -236,6 +243,36 @@ export const ProjectsPage: React.FC = () => {
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
});
|
||||
|
||||
const { data: allDisbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements'],
|
||||
queryFn: () => getDisbursements(1, 200),
|
||||
});
|
||||
|
||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, any[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const acts = await getActivities(p.id);
|
||||
map.set(p.id, acts);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
const handleOpenComplianceStatus = (p: Project, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedProjectForCompliance(p);
|
||||
setIsComplianceModalOpen(true);
|
||||
};
|
||||
|
||||
// Programme lookup map
|
||||
const programmeMap = useMemo(() => {
|
||||
const map = new Map<string | number, Programme>();
|
||||
@@ -280,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
|
||||
return map;
|
||||
}, [allBudgets]);
|
||||
|
||||
// Disbursed capital per project
|
||||
const disbursedByProject = useMemo(() => {
|
||||
const map = new Map<string | number, number>();
|
||||
allDisbursements.forEach((d) => {
|
||||
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
|
||||
const current = map.get(d.projectId) || 0;
|
||||
map.set(d.projectId, current + (Number(d.amount) || 0));
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allDisbursements]);
|
||||
|
||||
// Filtered Projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
@@ -349,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
|
||||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Project Variance (ZAR)',
|
||||
key: 'id',
|
||||
format: (_, p) => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
const pVariance = approvedBudget - projectValue;
|
||||
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
|
||||
|
||||
// Table Sorting
|
||||
const customExtractors = {
|
||||
@@ -373,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
|
||||
if (risk.isAtRisk) return 2;
|
||||
return 3;
|
||||
},
|
||||
variance: (p: Project) => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
return approvedBudget - projectValue;
|
||||
},
|
||||
};
|
||||
|
||||
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||||
@@ -976,13 +1046,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">Actions</th>
|
||||
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>Project Variance</span>
|
||||
<HelpTooltip
|
||||
title="Project Variance"
|
||||
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||
{loadingProjects ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-12 text-center text-slate-400 text-xs">
|
||||
<td colSpan={7} className="p-12 text-center text-slate-400 text-xs">
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading project directory...</span>
|
||||
@@ -991,7 +1072,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</tr>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-12 text-center text-slate-400">
|
||||
<td colSpan={7} className="p-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<FolderKanban className="w-10 h-10 text-slate-600" />
|
||||
<p className="text-sm font-semibold text-slate-300">No matching projects found</p>
|
||||
@@ -1165,17 +1246,53 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Row Action: View Project (Opens Large WBS & Gantt Console) */}
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Project Variance Column */}
|
||||
{(() => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||
const projectValue = pActsTotal - pDisbursedTotal;
|
||||
const pVariance = approvedBudget - projectValue;
|
||||
const isPos = pVariance >= 0;
|
||||
return (
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs border ${
|
||||
isPos
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||
>
|
||||
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Row Action: View Project & Compliance Status */}
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-56" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenProjectView(p, e)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
|
||||
title="Open Project View & Interactive Gantt Timeline"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View Project</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenComplianceStatus(p, e)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 rounded-xl transition cursor-pointer shadow-sm active:scale-95"
|
||||
title="View Compliance Status, Risks & Beneficiaries"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Compliance Status</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -1366,7 +1483,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{sortedIndicatorsData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-12 text-center text-slate-500">
|
||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||||
? 'No indicators match your filter criteria.'
|
||||
@@ -1388,10 +1505,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
setSelectedIndicatorForActual(ind);
|
||||
setActualInput(ind.actualAmount);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-600 px-3 py-1.5 rounded-lg border border-blue-500/25 transition cursor-pointer"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||||
title="Edit actual realized value in place"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>Record Actual</span>
|
||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Edit Actual</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1656,6 +1774,16 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
{/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */}
|
||||
<ProjectComplianceModal
|
||||
isOpen={isComplianceModalOpen}
|
||||
onClose={() => {
|
||||
setIsComplianceModalOpen(false);
|
||||
setSelectedProjectForCompliance(null);
|
||||
}}
|
||||
project={selectedProjectForCompliance}
|
||||
programme={selectedProjectForCompliance ? programmeMap.get(selectedProjectForCompliance.programmeId) || null : null}
|
||||
/>
|
||||
|
||||
<ProjectViewModal
|
||||
isOpen={isProjectViewModalOpen}
|
||||
onClose={() => {
|
||||
@@ -1726,6 +1854,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
setActiveModalProject(null);
|
||||
}}
|
||||
project={activeModalProject}
|
||||
onOpenIndicatorsForActivity={(actId) => {
|
||||
setInitialIndicatorActivityId(actId);
|
||||
setIsActivitiesModalOpen(false);
|
||||
setIsIndicatorsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Indicators Management Modal */}
|
||||
@@ -1734,8 +1867,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClose={() => {
|
||||
setIsIndicatorsModalOpen(false);
|
||||
setActiveModalProject(null);
|
||||
setInitialIndicatorActivityId(null);
|
||||
}}
|
||||
project={activeModalProject}
|
||||
initialActivityId={initialIndicatorActivityId}
|
||||
/>
|
||||
|
||||
{/* Risks Management Modal */}
|
||||
@@ -1878,7 +2013,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<Modal
|
||||
isOpen={!!selectedIndicatorForActual}
|
||||
onClose={() => setSelectedIndicatorForActual(null)}
|
||||
title="Record Indicator Actual Value"
|
||||
title="Edit 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)."
|
||||
>
|
||||
|
||||
@@ -57,9 +57,16 @@ import {
|
||||
getOrganisations,
|
||||
getAwards,
|
||||
getContracts,
|
||||
getProjects,
|
||||
getAuditLogs,
|
||||
getNotifications,
|
||||
notifyInviteApproved,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
getApprovedInviteIds,
|
||||
markInviteAsApprovedInStorage,
|
||||
} from '../services/notificationRulesService';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
@@ -73,6 +80,8 @@ import {
|
||||
NotificationStatus,
|
||||
NotificationPlatform,
|
||||
ActivityStatus,
|
||||
ApprovalStatus,
|
||||
ContractStatus,
|
||||
User,
|
||||
Invite,
|
||||
InviteRequest,
|
||||
@@ -104,7 +113,7 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||
|
||||
const { canView, canAdd, canExport } = useRbac();
|
||||
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||
const canViewInvites = canView('settings_invites');
|
||||
const canViewNotifications = canView('settings_notifications');
|
||||
@@ -146,7 +155,6 @@ export const SettingsPage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -235,35 +243,39 @@ export const SettingsPage: React.FC = () => {
|
||||
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
||||
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
||||
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
||||
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
|
||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||
const [inviteMessage, setInviteMessage] = useState('');
|
||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||
// Approved invites tracking state
|
||||
const currentTenantId = getCurrentUserTenantId();
|
||||
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
|
||||
getApprovedInviteIds(currentTenantId)
|
||||
);
|
||||
|
||||
const {
|
||||
data: invitesList = [],
|
||||
isLoading: loadingInvites,
|
||||
isFetching: isFetchingInvites,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryKey: ['invites'],
|
||||
queryFn: () => getInvites(1, 100),
|
||||
});
|
||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||
if (isMaker(inv.createdBy)) {
|
||||
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
|
||||
return;
|
||||
}
|
||||
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||
|
||||
const filteredInvites = useMemo(() => {
|
||||
return invitesList.filter((inv) => {
|
||||
const matchSearch =
|
||||
!inviteSearchTerm.trim() ||
|
||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
|
||||
const matchPlatform =
|
||||
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
|
||||
return matchSearch && matchPlatform;
|
||||
});
|
||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
|
||||
await notifyInviteApproved({
|
||||
invite: inv,
|
||||
}).catch(console.warn);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
success(
|
||||
'Invitation Approved',
|
||||
'Partner user has been notified via In-App and Email messages with link to Partner Portal (https://partner.ifri.khongisa.co.za/).'
|
||||
);
|
||||
};
|
||||
|
||||
const { data: organisations = [] } = useQuery({
|
||||
queryKey: ['organisations'],
|
||||
@@ -280,14 +292,136 @@ export const SettingsPage: React.FC = () => {
|
||||
queryFn: () => getContracts(1, 50),
|
||||
});
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => getProjects(1, 100),
|
||||
});
|
||||
|
||||
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
|
||||
const eligibleAwards = useMemo(() => {
|
||||
return awards.filter((aw) => {
|
||||
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
|
||||
if (!isApprovedAward) return false;
|
||||
const hasApprovedContract = contracts.some(
|
||||
(c) =>
|
||||
String(c.awardId) === String(aw.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
return hasApprovedContract;
|
||||
});
|
||||
}, [awards, contracts]);
|
||||
|
||||
const handleSelectAward = (awardId: string | number) => {
|
||||
setInviteAwardId(awardId);
|
||||
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
|
||||
if (selected) {
|
||||
setInvitedOrgId(selected.organisationId);
|
||||
const matchingContract = contracts.find(
|
||||
(c) =>
|
||||
String(c.awardId) === String(selected.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
if (matchingContract) {
|
||||
setInviteContractId(matchingContract.id);
|
||||
}
|
||||
setInviteProjectId(selected.projectId || '');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSendInviteModalOpen) {
|
||||
if (eligibleAwards.length > 0) {
|
||||
const currentAward =
|
||||
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
|
||||
setInviteAwardId(currentAward.id);
|
||||
setInvitedOrgId(currentAward.organisationId);
|
||||
const matchingContract = contracts.find(
|
||||
(c) =>
|
||||
String(c.awardId) === String(currentAward.id) &&
|
||||
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||
);
|
||||
if (matchingContract) {
|
||||
setInviteContractId(matchingContract.id);
|
||||
}
|
||||
setInviteProjectId(currentAward.projectId || '');
|
||||
} else {
|
||||
setInviteAwardId('');
|
||||
setInvitedOrgId('');
|
||||
setInviteContractId('');
|
||||
setInviteProjectId('');
|
||||
}
|
||||
}
|
||||
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||
|
||||
const {
|
||||
data: invitesList = [],
|
||||
isLoading: loadingInvites,
|
||||
isFetching: isFetchingInvites,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryKey: ['invites'],
|
||||
queryFn: () => getInvites(1, 100),
|
||||
});
|
||||
|
||||
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
|
||||
if (!inviteId) return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
|
||||
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
|
||||
try {
|
||||
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
|
||||
if (!userIdOrUuid) return '—';
|
||||
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
|
||||
const email = u?.email || getUserEmail(userIdOrUuid);
|
||||
return email && email !== '—' ? email : String(userIdOrUuid);
|
||||
};
|
||||
|
||||
const sendInviteMutation = useMutation({
|
||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, variables) => {
|
||||
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||
setIsSendInviteModalOpen(false);
|
||||
setInviteRecipientUuid('');
|
||||
setInviteMessage('');
|
||||
|
||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||
storeInvitePlatform(invId, variables.platform);
|
||||
|
||||
if (inviteAutoApprove) {
|
||||
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||
|
||||
notifyInviteApproved({
|
||||
invite: {
|
||||
id: invId,
|
||||
recipient: variables.recipient,
|
||||
invitedOrganisationId: variables.invitedOrganisationId,
|
||||
organisationId: variables.organisationId,
|
||||
awardId: variables.awardId,
|
||||
contractId: variables.contractId,
|
||||
projectId: variables.projectId,
|
||||
message: variables.message,
|
||||
},
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}).catch(console.warn);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Dispatch Error', err);
|
||||
@@ -366,6 +500,38 @@ export const SettingsPage: React.FC = () => {
|
||||
handleSort: handleNotifSort,
|
||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
||||
|
||||
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
|
||||
if (inv.platform !== undefined && inv.platform !== null) {
|
||||
return Number(inv.platform) as NotificationPlatform;
|
||||
}
|
||||
const stored = getStoredInvitePlatform(inv.id);
|
||||
if (stored !== undefined) return stored;
|
||||
|
||||
const linkedNotif = fetchedNotifications.find(
|
||||
(n) =>
|
||||
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
|
||||
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
|
||||
);
|
||||
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
|
||||
return Number(linkedNotif.platform) as NotificationPlatform;
|
||||
}
|
||||
return NotificationPlatform.Email;
|
||||
};
|
||||
|
||||
const filteredInvites = useMemo(() => {
|
||||
return invitesList.filter((inv) => {
|
||||
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
|
||||
const matchSearch =
|
||||
!inviteSearchTerm.trim() ||
|
||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||
recipientEmail.includes(inviteSearchTerm.toLowerCase());
|
||||
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||
const matchPlatform =
|
||||
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
|
||||
return matchSearch && matchPlatform;
|
||||
});
|
||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
|
||||
|
||||
// ==========================================
|
||||
// 4. Audit Log Ledger State
|
||||
// ==========================================
|
||||
@@ -434,9 +600,22 @@ export const SettingsPage: React.FC = () => {
|
||||
{
|
||||
header: 'Changes / Arguments',
|
||||
key: 'changes',
|
||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||
format: (val) => {
|
||||
if (!val) return '—';
|
||||
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Error Details',
|
||||
key: 'error',
|
||||
format: (val) => {
|
||||
if (!val) return '—';
|
||||
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||
},
|
||||
},
|
||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
||||
];
|
||||
|
||||
const uniqueAuditEntities = Array.from(
|
||||
@@ -506,13 +685,25 @@ export const SettingsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const inviteExportColumns: ExportColumn<Invite>[] = [
|
||||
{ header: 'Recipient (UUID)', key: 'recipient' },
|
||||
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
||||
{ header: 'Invitation Message', key: 'message' },
|
||||
{
|
||||
header: 'Recipient Email',
|
||||
key: 'recipient',
|
||||
format: (val) => resolveUserEmail(val),
|
||||
},
|
||||
{
|
||||
header: 'Channel / Platform',
|
||||
key: 'platform',
|
||||
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||
},
|
||||
{
|
||||
header: 'Target Organisation / Grant',
|
||||
key: 'invitedOrganisationId',
|
||||
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
|
||||
format: (val, inv) => {
|
||||
const orgId = val || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||
return `${orgName} • Award #${inv.awardId || '—'}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
||||
];
|
||||
@@ -538,7 +729,15 @@ export const SettingsPage: React.FC = () => {
|
||||
sortColumn: inviteSortCol,
|
||||
sortDirection: inviteSortDir,
|
||||
handleSort: handleInviteSort,
|
||||
} = useTableSort(filteredInvites, 'createdAt', 'desc');
|
||||
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
|
||||
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
|
||||
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||
invitedOrganisationId: (inv: Invite) => {
|
||||
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
return org?.name || `Organisation #${orgId || ''}`;
|
||||
},
|
||||
});
|
||||
|
||||
const getActionBadge = (action?: string | null) => {
|
||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||
@@ -1125,61 +1324,125 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto min-w-0 w-full">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[950px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||
Recipient (UUID)
|
||||
Recipient Email
|
||||
</SortableTh>
|
||||
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Channel
|
||||
</SortableTh>
|
||||
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Message
|
||||
</SortableTh>
|
||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Target Org / Grant
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
||||
Created Date
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingInvites ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
Loading invitations ledger...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedInvites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedInvites.map((inv, idx) => (
|
||||
sortedInvites.map((inv, idx) => {
|
||||
const isApproved =
|
||||
approvedInviteIds.includes(String(inv.id)) ||
|
||||
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||
approvedInviteIds.includes(String(idx));
|
||||
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||
const userEmail = resolveUserEmail(inv.recipient);
|
||||
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||
const isCreator = isMaker(inv.createdBy);
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||
<td className="py-3.5 px-5 text-slate-200 text-xs">
|
||||
<div className="font-medium text-white flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="truncate">{userEmail}</span>
|
||||
</div>
|
||||
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
|
||||
{inv.recipient}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{getNotificationPlatformLabel(inv.platform)}
|
||||
{getNotificationPlatformLabel(resolvedPlatform)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
||||
{inv.message}
|
||||
<td className="py-3.5 px-4 text-slate-300 text-xs">
|
||||
<div className="font-medium text-white">{orgName}</div>
|
||||
<div className="text-slate-400 text-[11px] mt-0.5">
|
||||
Award #{inv.awardId || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
||||
<td className="py-3.5 px-4">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Approved
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Pending Approval
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
||||
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400/90 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Partner Notified
|
||||
</span>
|
||||
) : canApproveRecord('settings_invites', inv.createdBy) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveInvite(inv, idx)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border border-emerald-500/30 transition shadow-xs cursor-pointer"
|
||||
title="Approve this invitation and issue InApp and Email notifications to the partner user"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
) : isCreator ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
|
||||
title="Maker-Checker: You created this invite and cannot approve it"
|
||||
>
|
||||
Maker (Creator)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
Pending Review
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -2395,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!inviteAwardId) {
|
||||
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
|
||||
return;
|
||||
}
|
||||
if (!inviteRecipientUuid || !inviteMessage) {
|
||||
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
|
||||
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
|
||||
return;
|
||||
}
|
||||
sendInviteMutation.mutate({
|
||||
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
||||
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
||||
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
|
||||
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
|
||||
awardId: inviteAwardId,
|
||||
contractId: inviteContractId,
|
||||
projectId: inviteProjectId || 0,
|
||||
recipient: inviteRecipientUuid,
|
||||
message: inviteMessage,
|
||||
platform: invitePlatform,
|
||||
@@ -2411,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{eligibleAwards.length === 0 ? (
|
||||
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
|
||||
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
|
||||
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
|
||||
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved & Active</strong>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Recipient User UUID *
|
||||
Recipient User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Recipient UUID"
|
||||
content="Unique directory user identifier or email for targeted delivery."
|
||||
title="Recipient Directory Identifier"
|
||||
content="Select a registered recipient user from directory or enter their unique user UUID."
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
{usersDirectory.length > 0 && (
|
||||
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
|
||||
)}
|
||||
</div>
|
||||
{usersDirectory.length > 0 && (
|
||||
<select
|
||||
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setInviteRecipientUuid(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 focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
|
||||
>
|
||||
<option value="">-- Choose from Registered Users --</option>
|
||||
{usersDirectory.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={inviteRecipientUuid}
|
||||
@@ -2433,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Approved Award *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Approved Award Required"
|
||||
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteAwardId}
|
||||
onChange={(e) => handleSelectAward(e.target.value)}
|
||||
disabled={eligibleAwards.length === 0}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
{eligibleAwards.length === 0 ? (
|
||||
<option value="">No approved awards available</option>
|
||||
) : (
|
||||
eligibleAwards.map((aw) => {
|
||||
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
|
||||
return (
|
||||
<option key={aw.id} value={aw.id}>
|
||||
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Approved Contract *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Associated Approved Contract"
|
||||
content="The active/approved contract linked to the selected award."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Target Organisation (Recipient Org)
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={(() => {
|
||||
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
|
||||
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
|
||||
})()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Project
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={(() => {
|
||||
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
|
||||
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
|
||||
})()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
@@ -2462,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Originating Organisation
|
||||
Originating Authority
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Inviting Authority"
|
||||
@@ -2485,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Award
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Linked Grant Award"
|
||||
content="Optional award attachment to link recipient permissions directly to grant administration."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteAwardId}
|
||||
onChange={(e) => setInviteAwardId(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 focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{awards.map((aw) => (
|
||||
<option key={aw.id} value={aw.id}>
|
||||
Award #{aw.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Associated Contract
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Linked Contract"
|
||||
content="Optional bilateral agreement linking the recipient to contract execution."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={inviteContractId}
|
||||
onChange={(e) => setInviteContractId(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 focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{contracts.map((ctr) => (
|
||||
<option key={ctr.id} value={ctr.id}>
|
||||
Contract #{ctr.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
@@ -2559,6 +2900,22 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-950/20 border border-blue-900/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inviteAutoApprove"
|
||||
checked={inviteAutoApprove}
|
||||
onChange={(e) => setInviteAutoApprove(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="inviteAutoApprove" className="text-xs text-slate-300 cursor-pointer select-none">
|
||||
<span className="font-semibold text-white block">Formally Approve Invitation & Notify Partner</span>
|
||||
<span className="text-[11px] text-slate-400 leading-relaxed block mt-0.5">
|
||||
Automatically marks invite as Approved and issues In-App and Email notifications directing the partner user to the Partner Portal (<strong className="text-blue-400 font-mono">https://partner.ifri.khongisa.co.za/</strong>).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2569,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendInviteMutation.isPending}
|
||||
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
||||
>
|
||||
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
||||
|
||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
title="Approved site visits are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
} = useQuery<TenantOrganisation[]>({
|
||||
queryKey: ['tenant-organisations', tenantId],
|
||||
queryFn: () => getTenantOrganisations(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Fetch tenant users directory to resolve linked user email per organisation
|
||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { canAdd } = useRbac();
|
||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
} = useQuery<Tenant[]>({
|
||||
queryKey: ['tenants-list'],
|
||||
queryFn: () => getTenants(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Forms
|
||||
@@ -962,6 +958,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
title="Provision New Tenant Partition"
|
||||
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
|
||||
size="md"
|
||||
>
|
||||
<form
|
||||
@@ -970,17 +967,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1 leading-relaxed">
|
||||
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
|
||||
<p>
|
||||
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
|
||||
New tenants are automatically initialized in the Active state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Tenant Organization Name <span className="text-rose-400">*</span>
|
||||
@@ -1007,7 +993,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||
@@ -1021,6 +1007,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
isOpen={isUpdateModalOpen}
|
||||
onClose={() => setIsUpdateModalOpen(false)}
|
||||
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
||||
subtitle="Modify tenant partition name and operational access status."
|
||||
size="md"
|
||||
>
|
||||
{selectedTenant && (
|
||||
@@ -1064,13 +1051,12 @@ export const TenantManagementPage: React.FC = () => {
|
||||
|
||||
{/* Warning when selecting Inactive or Disabled */}
|
||||
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
||||
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5">
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 flex items-start gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-white">Multitenancy Lockout Warning</p>
|
||||
<p className="text-[11px] leading-relaxed text-amber-300/90">
|
||||
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition.
|
||||
The backend API will deny requests until the tenant is restored to Active status.
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-semibold text-amber-300">Lockout Notice</p>
|
||||
<p className="text-[11px] text-amber-200/90 leading-relaxed">
|
||||
Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1087,7 +1073,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
||||
@@ -1102,66 +1088,47 @@ export const TenantManagementPage: React.FC = () => {
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
||||
subtitle="Partition boundary information, creation audit, and operational status."
|
||||
size="lg"
|
||||
>
|
||||
{selectedTenant && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-950/60 p-3.5 rounded-xl border border-slate-800 text-xs">
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
|
||||
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
||||
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
|
||||
<p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
|
||||
<div className="mt-1">
|
||||
<ActivityStatusBadge status={selectedTenant.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
|
||||
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
|
||||
<p className="text-xs font-mono text-slate-300 mt-0.5 truncate">
|
||||
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedTenant.updatedAt && (
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTenant.updatedBy && (
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
|
||||
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
||||
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
|
||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
Partition Scope & Authentication
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
When users authenticate, their security token contains claims identifying their tenant partition.
|
||||
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||
@@ -1171,7 +1138,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
setIsDetailModalOpen(false);
|
||||
handleOpenUpdate(selectedTenant);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
<span>Edit Tenant Status</span>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { apiClient } from './httpClient';
|
||||
import { toolsService } from './toolsService';
|
||||
import { toolsService } from '../utils/hashids';
|
||||
import {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||
notifyInviteApproved,
|
||||
notifyNewUserCreated,
|
||||
isApprovedOrActiveStatus,
|
||||
} from './notificationRulesService';
|
||||
|
||||
export {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||
notifyInviteApproved,
|
||||
notifyNewUserCreated,
|
||||
isApprovedOrActiveStatus,
|
||||
};
|
||||
import {
|
||||
getCurrentUserSub,
|
||||
getCurrentUserTenantId,
|
||||
@@ -64,6 +77,7 @@ import {
|
||||
AuditLog,
|
||||
ProblemDetails,
|
||||
ActivityStatus,
|
||||
ApprovalStatus,
|
||||
Tenant,
|
||||
TenantOrganisation,
|
||||
TenantUser,
|
||||
@@ -98,6 +112,8 @@ import {
|
||||
Claim,
|
||||
CreateUserRequest,
|
||||
AddUserClaimRequest,
|
||||
CreateNotificationRequest,
|
||||
UpdateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
@@ -237,16 +253,25 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
||||
};
|
||||
|
||||
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
||||
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
|
||||
const res = await apiClient.put('/v1/api/governance/organisations', {
|
||||
organisationId: toIntegerId(payload.organisationId),
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
idNumber: payload.idNumber || null,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: Number(payload.type),
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
status: orgStatus,
|
||||
});
|
||||
if (isApprovedOrActiveStatus(orgStatus)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Organisation',
|
||||
recordId: payload.organisationId,
|
||||
newStatus: orgStatus,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@@ -417,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
|
||||
}
|
||||
};
|
||||
|
||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
||||
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
|
||||
|
||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
if (!raw) return;
|
||||
const local: Instrument[] = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
export const getStoredInstructions = (): Instrument[] => [];
|
||||
|
||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored instruction duplicates', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredInstructions = (): Instrument[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = getStoredInstructions();
|
||||
const idx = current.findIndex(
|
||||
(i) => String(i.id) === String(inst.id) ||
|
||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
||||
);
|
||||
if (idx >= 0) {
|
||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
||||
} else {
|
||||
current.unshift(inst);
|
||||
}
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stored instruction', e);
|
||||
}
|
||||
};
|
||||
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||
|
||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||
let apiItems: Instrument[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||
if (Array.isArray(res.data)) {
|
||||
apiItems = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
} catch (err) {
|
||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredInstructionDuplicates(apiItems);
|
||||
return apiItems;
|
||||
}
|
||||
|
||||
// Fallback to local items only when API failed
|
||||
const localItems = getStoredInstructions().filter(
|
||||
(item) => String(item.programmeId) === String(programmeId)
|
||||
);
|
||||
return localItems;
|
||||
};
|
||||
|
||||
export const getInstructions = getInstruments;
|
||||
@@ -510,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
||||
}
|
||||
|
||||
const map = new Map<string, Instrument>();
|
||||
let hasApiData = false;
|
||||
|
||||
if (progList && progList.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
progList.map((pr) => getInstruments(pr.id))
|
||||
);
|
||||
results.forEach((res) => {
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
||||
hasApiData = true;
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasApiData || map.size > 0) {
|
||||
const allApiInstruments = Array.from(map.values());
|
||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
||||
return allApiInstruments;
|
||||
}
|
||||
|
||||
return getStoredInstructions();
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const getAllInstructions = getAllInstruments;
|
||||
@@ -544,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredInstructionDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackInstruction: Instrument = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredInstruction(fallbackInstruction);
|
||||
return fallbackInstruction;
|
||||
}
|
||||
|
||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const createInstruction = createInstrument;
|
||||
@@ -594,33 +514,16 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
status: Number(payload.status),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Funding Instrument',
|
||||
recordId: targetId!,
|
||||
newStatus: payload.status,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[updateInstrument] API update failed:', err);
|
||||
}
|
||||
|
||||
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
||||
const updatedInstrument: Instrument = {
|
||||
id: targetId!,
|
||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
||||
code: payload.code || existing?.code || null,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
createdBy: existing?.createdBy || 'system',
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedBy: payload.userId || 'system',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
saveStoredInstruction(updatedInstrument);
|
||||
}
|
||||
return updatedInstrument;
|
||||
};
|
||||
|
||||
export const updateInstruction = updateInstrument;
|
||||
@@ -665,12 +568,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
|
||||
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
||||
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
||||
}
|
||||
const progStatus = Number(payload.status) || 1;
|
||||
const res = await apiClient.put('/v1/api/projects/programme', {
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
||||
status: Number(payload.status) || 1,
|
||||
status: progStatus,
|
||||
});
|
||||
if (isApprovedOrActiveStatus(progStatus)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Programme',
|
||||
recordId: payload.programmeId,
|
||||
newStatus: progStatus,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@@ -723,6 +635,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
|
||||
projectId: toIntegerId(payload.projectId),
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Project',
|
||||
recordId: payload.projectId,
|
||||
newStatus: payload.status,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
|
||||
@@ -824,6 +743,14 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
|
||||
priority: Number(payload.priority) || 1,
|
||||
status: Number(payload.status) || 1,
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Milestone',
|
||||
recordId: payload.milestoneId,
|
||||
newStatus: payload.status,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
||||
@@ -882,6 +809,13 @@ export const updateAward = async (payload: UpdateAwardRequest): Promise<void> =>
|
||||
amount: Number(payload.amount),
|
||||
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
||||
});
|
||||
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Award',
|
||||
recordId: payload.awardId,
|
||||
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
||||
@@ -1016,6 +950,14 @@ export const updateContract = async (payload: UpdateContractRequest): Promise<vo
|
||||
contractId: toIntegerId(payload.contractId),
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Contract',
|
||||
recordId: payload.contractId,
|
||||
newStatus: payload.status,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export const uploadContractFile = async (
|
||||
@@ -1076,6 +1018,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
|
||||
budgetId: toIntegerId(payload.budgetId),
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Budget',
|
||||
recordId: payload.budgetId,
|
||||
newStatus: payload.status,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1114,6 +1063,11 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
|
||||
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
||||
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
||||
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Disbursement',
|
||||
recordId: disbursementId,
|
||||
newStatus: ApprovalStatus.Approved,
|
||||
}).catch(console.warn);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1216,10 +1170,12 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
|
||||
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
||||
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
||||
projectId: toIntegerId(payload.projectId),
|
||||
activityId: toNullableIntegerId(payload.activityId),
|
||||
name: payload.name,
|
||||
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
|
||||
baselineAmount: Number(payload.baselineAmount) || 0,
|
||||
targetAmount: Number(payload.targetAmount) || 0,
|
||||
targetCost: Number(payload.targetCost) || 0,
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
@@ -1273,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||
try {
|
||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||
return res.data || [];
|
||||
} catch (err: any) {
|
||||
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||
try {
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||
return rawRes.data || [];
|
||||
} catch (fallbackErr: any) {
|
||||
if (fallbackErr?.response?.status === 404) {
|
||||
@@ -1312,6 +1268,13 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
|
||||
evidenceType: Number(payload.evidenceType),
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Evidence',
|
||||
recordId: payload.evidenceId,
|
||||
newStatus: payload.status,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1551,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
||||
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||
awardId: toSafeInt64String(payload.awardId),
|
||||
contractId: toSafeInt64String(payload.contractId),
|
||||
projectId: toSafeInt64String(payload.projectId || 0),
|
||||
recipient: payload.recipient,
|
||||
message: payload.message,
|
||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||
@@ -1612,7 +1576,63 @@ export const getNotifications = async (organisationId?: string | number): Promis
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getUserNotifications = async (userId?: string): Promise<Notification[]> => {
|
||||
try {
|
||||
const url = userId ? `/v1/api/communications/notifications/user/${encodeURIComponent(userId)}` : `/v1/api/communications/notifications/inbox`;
|
||||
const res = await apiClient.get<Notification[]>(url);
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
return res.data;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[getUserNotifications] API error:', err);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createNotification = async (payload: CreateNotificationRequest): Promise<void> => {
|
||||
const tenantId = getCurrentUserTenantId();
|
||||
const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
||||
? toIntegerId(payload.organisationId)
|
||||
: (tenantId !== null ? tenantId : 1);
|
||||
|
||||
await apiClient.post('/v1/api/communications/notifications', {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipient,
|
||||
platform: payload.platform,
|
||||
subject: payload.subject || null,
|
||||
destination: payload.destination || 'In-App User Inbox',
|
||||
message: payload.message,
|
||||
status: payload.status !== undefined
|
||||
? payload.status
|
||||
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateNotification = async (payload: UpdateNotificationRequest): Promise<void> => {
|
||||
await apiClient.put('/v1/api/communications/notifications', {
|
||||
notificationId: toIntegerId(payload.notificationId),
|
||||
destination: payload.destination || undefined,
|
||||
status: payload.status,
|
||||
sentAt: payload.sentAt || new Date().toISOString(),
|
||||
error: payload.error || null,
|
||||
});
|
||||
};
|
||||
|
||||
export const markNotificationAsRead = async (notificationId: number | string): Promise<void> => {
|
||||
await updateNotification({
|
||||
notificationId: toIntegerId(notificationId),
|
||||
status: NotificationStatus.Read,
|
||||
});
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||
await updateNotification({
|
||||
notificationId: toIntegerId(notificationId),
|
||||
status: NotificationStatus.Archived,
|
||||
});
|
||||
};
|
||||
|
||||
// 9. Governance: Tenants APIs
|
||||
// ==========================================
|
||||
export const getTenants = async (): Promise<Tenant[]> => {
|
||||
@@ -1638,6 +1658,14 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
|
||||
name: payload.name,
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Tenant',
|
||||
recordId: payload.tenantId,
|
||||
newStatus: payload.status,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
||||
@@ -1741,14 +1769,41 @@ export const updateBeneficiary = async (payload: UpdateBeneficiaryRequest): Prom
|
||||
|
||||
export const getOutcome = async (outcomeId: string | number): Promise<Outcome> => {
|
||||
const hashedOutcomeId = await toolsService.encodeId(outcomeId);
|
||||
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/${hashedOutcomeId}`);
|
||||
try {
|
||||
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcome/${hashedOutcomeId}`);
|
||||
return res.data;
|
||||
} catch {
|
||||
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/single/${hashedOutcomeId}`);
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => {
|
||||
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
|
||||
try {
|
||||
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}/outcomes`);
|
||||
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) {
|
||||
try {
|
||||
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
|
||||
return res.data || [];
|
||||
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getProjectOutcomes = async (projectId: string | number): Promise<Outcome[]> => {
|
||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||
try {
|
||||
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/${hashedProjectId}/outcomes`);
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => {
|
||||
@@ -1756,12 +1811,13 @@ export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void
|
||||
beneficiaryId: toIntegerId(payload.beneficiaryId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : 1,
|
||||
};
|
||||
try {
|
||||
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
|
||||
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) {
|
||||
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
|
||||
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -1773,7 +1829,16 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
|
||||
outcomeId: toIntegerId(payload.outcomeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Outcome',
|
||||
recordId: payload.outcomeId,
|
||||
newStatus: payload.status,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1835,6 +1900,14 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
|
||||
revisedBudget: Number(payload.revisedBudget),
|
||||
status: Number(payload.status),
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Change Request',
|
||||
recordId: payload.changeRequestId,
|
||||
newStatus: payload.status,
|
||||
title: payload.title,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1882,6 +1955,8 @@ export const createComplianceItem = async (payload: CreateComplianceItemRequest)
|
||||
projectId: toIntegerId(payload.projectId),
|
||||
title: payload.title,
|
||||
requirements: payload.requirements,
|
||||
status: payload.status !== undefined ? Number(payload.status) : 1,
|
||||
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1891,7 +1966,16 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
|
||||
title: payload.title,
|
||||
requirements: payload.requirements,
|
||||
status: Number(payload.status),
|
||||
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Compliance Item',
|
||||
recordId: payload.complianceItemId,
|
||||
newStatus: payload.status,
|
||||
title: payload.title,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1959,6 +2043,15 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
assignedTo: payload.assignedTo,
|
||||
});
|
||||
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
|
||||
if (isApprovedOrActiveStatus(issueStatus)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Issue',
|
||||
recordId: payload.issueId,
|
||||
newStatus: issueStatus,
|
||||
title: payload.title,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -2023,78 +2116,31 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
||||
status: Number(payload.status),
|
||||
visitedAt: visitedAtIso,
|
||||
});
|
||||
};
|
||||
|
||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
||||
|
||||
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
||||
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
||||
if (!raw) return;
|
||||
const local: Rule[] = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
|
||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored rule duplicates', e);
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Site Visit',
|
||||
recordId: payload.siteVisitId,
|
||||
newStatus: payload.status,
|
||||
title: payload.inspectorName,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredRules = (): Rule[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
|
||||
|
||||
export const saveStoredRule = (rule: Rule): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = getStoredRules();
|
||||
const idx = current.findIndex(
|
||||
(r) => String(r.id) === String(rule.id) ||
|
||||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
||||
);
|
||||
if (idx >= 0) {
|
||||
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
||||
} else {
|
||||
current.unshift(rule);
|
||||
}
|
||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stored rule', e);
|
||||
}
|
||||
};
|
||||
export const getStoredRules = (): Rule[] => [];
|
||||
|
||||
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||
|
||||
// ==========================================
|
||||
// 15. Execution: Programme Rules APIs
|
||||
// ==========================================
|
||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||
try {
|
||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||
if (res.data) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch {
|
||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
||||
if (localRule) return localRule;
|
||||
throw new Error('Rule not found');
|
||||
}
|
||||
throw new Error('Rule not found');
|
||||
};
|
||||
|
||||
@@ -2111,38 +2157,16 @@ export const getRules = async (
|
||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||
programmeId?: string | number
|
||||
): Promise<Rule[]> => {
|
||||
let apiRules: Rule[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||
page: pagination.page || 1,
|
||||
pageSize: pagination.pageSize || 100,
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
apiRules = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[getRules] Error fetching from backend API:', err);
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredRuleDuplicates(apiRules);
|
||||
let result = apiRules;
|
||||
let result = Array.isArray(res.data) ? res.data : [];
|
||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback to local storage only if API call failed
|
||||
const localRules = getStoredRules();
|
||||
let combined = localRules;
|
||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
||||
}
|
||||
return combined;
|
||||
};
|
||||
|
||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||
@@ -2154,44 +2178,12 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackRule: Rule = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(fallbackRule);
|
||||
return fallbackRule;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||
const validUserId = toValidUuid(payload.userId);
|
||||
try {
|
||||
await apiClient.put('/v1/api/execution/rules', {
|
||||
ruleId: toIntegerId(payload.ruleId),
|
||||
userId: validUserId,
|
||||
@@ -2201,23 +2193,6 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||
version: Number(payload.version || 1),
|
||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[updateRule] API update failed:', err);
|
||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
||||
const updatedRule: Rule = {
|
||||
id: payload.ruleId,
|
||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
createdBy: existing?.createdBy || validUserId,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedBy: validUserId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(updatedRule);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -81,36 +83,18 @@ apiClient.interceptors.request.use(
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
config.url?.includes('garage');
|
||||
|
||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
||||
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||
const headers = config.headers || {};
|
||||
const hasNoCacheHeader =
|
||||
headers['Cache-Control'] === 'no-cache' ||
|
||||
headers['cache-control'] === 'no-cache' ||
|
||||
headers['Pragma'] === 'no-cache';
|
||||
|
||||
if (hasNoCacheHeader) {
|
||||
// Explicit freshness requested: attach fresh timestamp
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
} else {
|
||||
// Standard GET: attach current mutation epoch
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
}
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
if (!config.params._v && !config.params._t) {
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User Sync & Tenant Lifecycle Guard:
|
||||
// All API calls require an authenticated and enabled user in the backend system.
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
ApprovalStatus,
|
||||
ActivityStatus,
|
||||
ContractStatus,
|
||||
ComplianceStatus,
|
||||
User,
|
||||
Invite,
|
||||
CreateNotificationRequest,
|
||||
} from '../types';
|
||||
import { apiClient } from './httpClient';
|
||||
import {
|
||||
getCurrentUserSub,
|
||||
getCurrentUserClaims,
|
||||
getCurrentUserTenantId,
|
||||
} from '../auth/AuthProvider';
|
||||
|
||||
export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/';
|
||||
export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/';
|
||||
|
||||
/**
|
||||
* Dispatches a notification request directly via apiClient
|
||||
*/
|
||||
export async function dispatchNotification(payload: CreateNotificationRequest): Promise<void> {
|
||||
const tenantId = getCurrentUserTenantId();
|
||||
const orgId =
|
||||
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
||||
? Number(payload.organisationId)
|
||||
: (tenantId !== null ? tenantId : 1);
|
||||
|
||||
await apiClient.post('/v1/api/communications/notifications', {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipient,
|
||||
platform: payload.platform,
|
||||
subject: payload.subject || null,
|
||||
destination: payload.destination || 'In-App User Inbox',
|
||||
message: payload.message,
|
||||
status: payload.status !== undefined ? payload.status : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves users from directory directly via apiClient
|
||||
*/
|
||||
export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise<User[]> {
|
||||
try {
|
||||
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, {});
|
||||
return res.data || [];
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Failed to fetch users directory:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether a given status corresponds to "Approved" or "Active".
|
||||
*/
|
||||
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
|
||||
// Numeric enum matching
|
||||
const numStatus = Number(status);
|
||||
if (
|
||||
numStatus === ApprovalStatus.Approved || // 3
|
||||
numStatus === ActivityStatus.Active || // 1
|
||||
numStatus === ContractStatus.Active || // 3
|
||||
numStatus === ContractStatus.Executed || // 3
|
||||
numStatus === ComplianceStatus.Met || // 2
|
||||
numStatus === ComplianceStatus.Exceeded // 3
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// String matching
|
||||
const strStatus = String(status).toLowerCase().trim();
|
||||
return (
|
||||
strStatus === 'approved' ||
|
||||
strStatus === 'active' ||
|
||||
strStatus === 'executed' ||
|
||||
strStatus === 'met' ||
|
||||
strStatus === 'exceeded' ||
|
||||
strStatus === 'verified'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a friendly label for the approved or active status.
|
||||
*/
|
||||
export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string {
|
||||
if (status === undefined || status === null) return 'Active';
|
||||
const numStatus = Number(status);
|
||||
if (numStatus === ApprovalStatus.Approved) return 'Approved';
|
||||
if (numStatus === ActivityStatus.Active) return 'Active';
|
||||
if (numStatus === ContractStatus.Active) return 'Active';
|
||||
if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)';
|
||||
|
||||
const str = String(status);
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Tenant Managers and Executives within the active tenant context.
|
||||
* Falls back to the currently logged in user if directory lookup is restricted or empty.
|
||||
*/
|
||||
export async function getTenantManagersAndExecutives(): Promise<User[]> {
|
||||
try {
|
||||
const tenantId = getCurrentUserTenantId();
|
||||
const users = await fetchDirectoryUsers(1, 200);
|
||||
|
||||
const managersAndExecutives = users.filter((u) => {
|
||||
const roleStr = String(u.role || '').toLowerCase();
|
||||
const isManagerOrExec =
|
||||
roleStr.includes('manager') ||
|
||||
roleStr.includes('executive') ||
|
||||
roleStr.includes('director') ||
|
||||
roleStr === '2' || // Role ID 2 = Executive
|
||||
roleStr === '4'; // Role ID 4 = Manager
|
||||
|
||||
const matchesTenant =
|
||||
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
|
||||
|
||||
return isManagerOrExec && matchesTenant;
|
||||
});
|
||||
|
||||
if (managersAndExecutives.length > 0) {
|
||||
return managersAndExecutives;
|
||||
}
|
||||
|
||||
// Secondary fallback: Any user with admin or supervisor if no direct manager/executive found
|
||||
const elevatedUsers = users.filter((u) => {
|
||||
const roleStr = String(u.role || '').toLowerCase();
|
||||
return (
|
||||
roleStr.includes('admin') ||
|
||||
roleStr.includes('supervisor') ||
|
||||
roleStr === '1' ||
|
||||
roleStr === '3'
|
||||
);
|
||||
});
|
||||
|
||||
if (elevatedUsers.length > 0) {
|
||||
return elevatedUsers;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err);
|
||||
}
|
||||
|
||||
// Graceful fallback to authenticated user identity so telemetry notification is recorded
|
||||
const currentSub = getCurrentUserSub();
|
||||
const currentClaims = getCurrentUserClaims();
|
||||
if (currentSub) {
|
||||
return [
|
||||
{
|
||||
id: currentSub,
|
||||
email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za',
|
||||
status: ActivityStatus.Active,
|
||||
lastLoginAt: null,
|
||||
role: 'Manager',
|
||||
name: (currentClaims.name as string) || 'Tenant Manager',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface RecordStatusChangeNotificationParams {
|
||||
recordType: string;
|
||||
recordId: string | number;
|
||||
newStatus: number | string;
|
||||
title?: string;
|
||||
previousStatus?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RULE 1:
|
||||
* "Whenever a record change status to Approved, Active (for contract or similar records) exclusding users;
|
||||
* the system must issue an InApp and Email message to Tenant Managers and Executives."
|
||||
*/
|
||||
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
|
||||
params: RecordStatusChangeNotificationParams
|
||||
): Promise<void> {
|
||||
const { recordType, recordId, newStatus, title } = params;
|
||||
|
||||
// Rule constraint: Strictly exclude users
|
||||
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger when the target status is Approved or Active
|
||||
if (!isApprovedOrActiveStatus(newStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabel = getApprovedOrActiveStatusLabel(newStatus);
|
||||
const orgId = getCurrentUserTenantId() || 1;
|
||||
const managersAndExecutives = await getTenantManagersAndExecutives();
|
||||
|
||||
if (managersAndExecutives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordDescriptor = title
|
||||
? `${recordType} "${title}" (#${recordId})`
|
||||
: `${recordType} #${recordId}`;
|
||||
|
||||
const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`;
|
||||
const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}\n- **Portal Reference:** [Open PFM Console](${CONSOLE_PORTAL_URL})\n\nPlease review the updated portfolio commitments accordingly.`;
|
||||
|
||||
const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`;
|
||||
const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}\n\nYou can inspect this record in the PFM Management Console: [Access Management Console](${CONSOLE_PORTAL_URL}).`;
|
||||
|
||||
// Dispatch both InApp and Email notifications to each Tenant Manager and Executive
|
||||
for (const recipient of managersAndExecutives) {
|
||||
if (!recipient.id) continue;
|
||||
const recipientEmail = recipient.email || 'manager@pfm.org.za';
|
||||
|
||||
// 1. In-App Notification
|
||||
try {
|
||||
const inAppReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: recipient.id,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: inAppSubject,
|
||||
destination: recipientEmail,
|
||||
message: inAppMessage,
|
||||
status: NotificationStatus.Delivered,
|
||||
};
|
||||
await dispatchNotification(inAppReq);
|
||||
} catch (err) {
|
||||
console.warn(`[NotificationRules] Failed to send InApp message to ${recipient.id}:`, err);
|
||||
}
|
||||
|
||||
// 2. Email Notification
|
||||
try {
|
||||
const emailReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: recipient.id,
|
||||
platform: NotificationPlatform.Email,
|
||||
subject: emailSubject,
|
||||
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
|
||||
message: emailMessage,
|
||||
status: NotificationStatus.Queued,
|
||||
};
|
||||
await dispatchNotification(emailReq);
|
||||
} catch (err) {
|
||||
console.warn(`[NotificationRules] Failed to send Email message to ${recipientEmail}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InviteApprovedNotificationParams {
|
||||
invite: Partial<Invite> & {
|
||||
id: number | string;
|
||||
recipient: string;
|
||||
invitedOrganisationId?: number | string;
|
||||
organisationId?: number | string;
|
||||
awardId?: number | string;
|
||||
contractId?: number | string;
|
||||
message?: string;
|
||||
};
|
||||
partnerUser?: User | null;
|
||||
partnerEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RULE 2:
|
||||
* "When an Invite is Approved, the Partner user must be notified via Email and InApp message as well."
|
||||
*/
|
||||
export async function notifyInviteApproved(
|
||||
params: InviteApprovedNotificationParams
|
||||
): Promise<void> {
|
||||
const { invite, partnerUser, partnerEmail } = params;
|
||||
if (!invite.recipient) return;
|
||||
|
||||
const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1;
|
||||
const targetEmail = partnerEmail || partnerUser?.email || invite.recipient;
|
||||
|
||||
const inAppSubject = 'Onboarding Invitation Approved: Access to Partner Portal Granted';
|
||||
const inAppMessage = `### Statutory Invitation Approved\n\nYour statutory onboarding invitation for **Organisation #${invite.invitedOrganisationId || invite.organisationId}** (Funding Award #${invite.awardId || 'N/A'}, Contract #${invite.contractId || 'N/A'}) has been formally **Approved**.\n\nYou may now access the **PFM Partner Portal** to review contractual agreements, complete KYC compliance, and submit milestone deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})`;
|
||||
|
||||
const emailSubject = '[PFM Partner Portal] Statutory Invitation Approved - Welcome Onboard';
|
||||
const emailMessage = `### Formal Statutory Onboarding Approval\n\nDear Grantee / Project Partner,\n\nWe are pleased to inform you that your statutory onboarding invitation has been formally **Approved** by the funding authority.\n\n**Invitation Summary:**\n- **Recipient Key:** \`${invite.recipient}\`\n- **Target Organisation:** #${invite.invitedOrganisationId || invite.organisationId}\n- **Funding Award ID:** #${invite.awardId || 'N/A'}\n- **Contract Reference:** #${invite.contractId || 'N/A'}\n- **Authorisation Status:** **Approved & Active**\n\n**Next Steps:**\nPlease log in to the PFM Partner Portal at: **${PARTNER_PORTAL_URL}**\n\nOnce logged in, please complete your statutory recipient onboarding, verify bank details, and review milestone schedules.`;
|
||||
|
||||
// 1. In-App Notification to Partner
|
||||
try {
|
||||
const inAppReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: invite.recipient,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: inAppSubject,
|
||||
destination: targetEmail,
|
||||
message: inAppMessage,
|
||||
status: NotificationStatus.Delivered,
|
||||
};
|
||||
await dispatchNotification(inAppReq);
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Failed to issue InApp invite approval notification:', err);
|
||||
}
|
||||
|
||||
// 2. Email Notification to Partner
|
||||
try {
|
||||
const emailReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: invite.recipient,
|
||||
platform: NotificationPlatform.Email,
|
||||
subject: emailSubject,
|
||||
destination: `To: ${targetEmail} | From: PFM Statutory Communications`,
|
||||
message: emailMessage,
|
||||
status: NotificationStatus.Queued,
|
||||
};
|
||||
await dispatchNotification(emailReq);
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Failed to issue Email invite approval notification:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewUserCreatedNotificationParams {
|
||||
userId: string;
|
||||
email: string;
|
||||
isPartner: boolean;
|
||||
roleName?: string;
|
||||
tenantId?: number | string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RULE 3:
|
||||
* "When a new user is created, an InApp and Email message must be issued to them notifying them of this
|
||||
* and directing them to login (https://console.ifri.khongisa.co.za/),
|
||||
* PartnerUsers (users with Partner role) should be instructed to navigate to the partner portal (https://partner.ifri.khongisa.co.za/)"
|
||||
*/
|
||||
export async function notifyNewUserCreated(
|
||||
params: NewUserCreatedNotificationParams
|
||||
): Promise<void> {
|
||||
const { userId, email, isPartner, roleName, tenantId } = params;
|
||||
if (!userId || !email) return;
|
||||
|
||||
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
|
||||
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
|
||||
const targetPortalName = isPartner ? 'PFM Partner Portal' : 'PFM Management Console';
|
||||
const roleDisplay = roleName || (isPartner ? 'Partner' : 'Console User');
|
||||
|
||||
const inAppSubject = `Welcome to PFM - Your Account Has Been Created`;
|
||||
const inAppMessage = isPartner
|
||||
? `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nAs a designated project partner, please navigate to the **Partner Portal** to complete statutory recipient onboarding, verify banking details, and collaborate on project deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})\n\n**Registered Email:** \`${email}\``
|
||||
: `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nPlease log in to the **Management Console** using your registered corporate credentials to access your administrative dashboard and governance responsibilities:\n\n👉 [Open Management Console](${CONSOLE_PORTAL_URL})\n\n**Registered Email:** \`${email}\``;
|
||||
|
||||
const emailSubject = `Welcome to PFM - Account Provisioned & Login Instructions`;
|
||||
const emailMessage = isPartner
|
||||
? `### Welcome to the PFM Platform - Partner Account Activation\n\nDear Partner,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **Partner** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Dedicated Portal URL:** **${PARTNER_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Partner Portal: [${PARTNER_PORTAL_URL}](${PARTNER_PORTAL_URL})\n2. Sign in using your registered email address.\n3. Complete your organization's statutory onboarding checklist and review assigned grant awards.`
|
||||
: `### Welcome to the PFM Platform - User Account Provisioned\n\nDear Colleague,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **${roleDisplay}** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Management Console URL:** **${CONSOLE_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Management Console: [${CONSOLE_PORTAL_URL}](${CONSOLE_PORTAL_URL})\n2. Sign in using your registered credentials.\n3. Access your assigned programmes, portfolios, and operational tools.`;
|
||||
|
||||
// 1. In-App Message
|
||||
try {
|
||||
const inAppReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: userId,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: inAppSubject,
|
||||
destination: email,
|
||||
message: inAppMessage,
|
||||
status: NotificationStatus.Delivered,
|
||||
};
|
||||
await dispatchNotification(inAppReq);
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Failed to send InApp message for new user:', err);
|
||||
}
|
||||
|
||||
// 2. Email Message
|
||||
try {
|
||||
const emailReq: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: userId,
|
||||
platform: NotificationPlatform.Email,
|
||||
subject: emailSubject,
|
||||
destination: `To: ${email} | From: PFM Security & Identity Administration`,
|
||||
message: emailMessage,
|
||||
status: NotificationStatus.Queued,
|
||||
};
|
||||
await dispatchNotification(emailReq);
|
||||
} catch (err) {
|
||||
console.warn('[NotificationRules] Failed to send Email message for new user:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local storage persistence helper for approved invitations ledger state.
|
||||
*/
|
||||
const APPROVED_INVITES_STORAGE_KEY_PREFIX = 'pfm_approved_invites_';
|
||||
|
||||
export function getApprovedInviteIds(tenantId?: number | string | null): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function markInviteAsApprovedInStorage(
|
||||
inviteId: string | number,
|
||||
tenantId?: number | string | null
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
|
||||
const current = getApprovedInviteIds(tenantId);
|
||||
const strId = String(inviteId);
|
||||
if (!current.includes(strId)) {
|
||||
const updated = [...current, strId];
|
||||
localStorage.setItem(key, JSON.stringify(updated));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
|
||||
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
|
||||
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims';
|
||||
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version';
|
||||
export const CURRENT_RBAC_CACHE_VERSION = 'v2.1';
|
||||
export const CURRENT_RBAC_CACHE_VERSION = 'v2.2';
|
||||
|
||||
/**
|
||||
* Invalidates and purges legacy cached claims if the schema version differs.
|
||||
@@ -55,6 +55,7 @@ export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
||||
],
|
||||
supervisor: [
|
||||
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
|
||||
{ claimId: 38, claimType: 'compliance', claimValue: 'view, export' },
|
||||
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
|
||||
@@ -66,6 +67,7 @@ export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
||||
],
|
||||
manager: [
|
||||
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
|
||||
{ claimId: 39, claimType: 'compliance', claimValue: 'all' },
|
||||
{ claimId: 11, claimType: 'projects_milestones', claimValue: 'all' },
|
||||
{ claimId: 12, claimType: 'grant_financials', claimValue: 'all' },
|
||||
{ claimId: 13, claimType: 'evidence_risks', claimValue: 'all' },
|
||||
@@ -80,6 +82,7 @@ export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
||||
],
|
||||
executive: [
|
||||
{ claimId: 1, claimType: 'dashboard', claimValue: 'all' },
|
||||
{ claimId: 40, claimType: 'compliance', claimValue: 'all' },
|
||||
{ claimId: 2, claimType: 'governance', claimValue: 'all' },
|
||||
{ claimId: 3, claimType: 'projects_milestones', claimValue: 'all' },
|
||||
{ claimId: 4, claimType: 'grant_financials', claimValue: 'all' },
|
||||
@@ -517,7 +520,7 @@ export async function resolveUserRolesAndClaims(
|
||||
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||
const roles = await fetchAndCacheSystemRoles();
|
||||
await Promise.allSettled(
|
||||
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
|
||||
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -560,7 +563,7 @@ export async function getSystemClaims(): Promise<Claim[]> {
|
||||
try {
|
||||
const roles = await fetchAndCacheSystemRoles();
|
||||
for (const r of roles) {
|
||||
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
|
||||
const claims = await fetchAndCacheRoleClaims(r.id || r.Id || '', r.name || r.Name || '');
|
||||
claims.forEach((c) => {
|
||||
const norm = normalizeClaim(c);
|
||||
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTenant } from './api';
|
||||
import { getTenant, getTenants } from './api';
|
||||
import { ActivityStatus, Tenant } from '../types';
|
||||
import { setActivationPending } from './userSyncService';
|
||||
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
|
||||
|
||||
export interface TenantLockoutResult {
|
||||
isActive: boolean;
|
||||
@@ -9,21 +10,10 @@ export interface TenantLockoutResult {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
|
||||
let cachedTenantLockout: {
|
||||
key: string;
|
||||
result: TenantLockoutResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
||||
|
||||
export function clearTenantLockoutCache(): void {
|
||||
cachedTenantLockout = null;
|
||||
// Caching removed to guarantee fresh tenant state on every verification
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
|
||||
*/
|
||||
export function isStatusActive(status: any): boolean {
|
||||
if (status === undefined || status === null || status === '') {
|
||||
return false;
|
||||
@@ -40,9 +30,6 @@ export function isStatusActive(status: any): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a human-readable status label from numeric or string status
|
||||
*/
|
||||
export function getStatusLabel(status: any): string {
|
||||
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
||||
return 'Active';
|
||||
@@ -65,76 +52,110 @@ export function getStatusLabel(status: any): string {
|
||||
return 'Not Specified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the GetTenant endpoint for the provided tenant identifier.
|
||||
*
|
||||
* Rules:
|
||||
* - If nothing is returned (empty response, 404, or failure) OR
|
||||
* - There is a response object and its Status is not Active
|
||||
* Then lockout is triggered and the existing lockout modal is shown.
|
||||
*/
|
||||
function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
|
||||
if (val === undefined || val === null || val === '') return false;
|
||||
if (typeof val === 'number') {
|
||||
return !isNaN(val) && isFinite(val) && val > 0;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
const trimmed = val.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return parseInt(trimmed, 10) > 0;
|
||||
}
|
||||
return isAlreadyHashId(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function verifyTenantLockout(
|
||||
tenantId?: number | string | null,
|
||||
tenantClaim?: string | null,
|
||||
forceRefresh = false
|
||||
): Promise<TenantLockoutResult> {
|
||||
const primaryId =
|
||||
tenantId !== undefined && tenantId !== null && tenantId !== ''
|
||||
? tenantId
|
||||
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
|
||||
? tenantClaim
|
||||
: null;
|
||||
|
||||
if (primaryId === null || primaryId === undefined || primaryId === '') {
|
||||
const errorMsg =
|
||||
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
|
||||
setActivationPending(true, errorMsg);
|
||||
return {
|
||||
isActive: false,
|
||||
tenant: null,
|
||||
errorMessage: errorMsg,
|
||||
};
|
||||
// Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
|
||||
let candidateId: number | string | null = null;
|
||||
if (isValidHashableOrHashedTenantId(tenantId)) {
|
||||
candidateId = tenantId;
|
||||
} else if (isValidHashableOrHashedTenantId(tenantClaim)) {
|
||||
candidateId = tenantClaim;
|
||||
}
|
||||
|
||||
const cacheKey = String(primaryId).trim().toLowerCase();
|
||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||
if (
|
||||
cachedTenantLockout.result.isActive &&
|
||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedTenantLockout.result;
|
||||
}
|
||||
}
|
||||
// Fresh check: query endpoint directly without cache
|
||||
|
||||
let tenantRes: Tenant | null = null;
|
||||
let lastError: any = null;
|
||||
|
||||
// 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId}
|
||||
if (candidateId !== null) {
|
||||
try {
|
||||
tenantRes = await getTenant(primaryId);
|
||||
const hashedId = encodeHashIdSync(candidateId);
|
||||
tenantRes = await getTenant(hashedId || candidateId);
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
tenantRes = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
|
||||
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
|
||||
// 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
|
||||
if (!tenantRes) {
|
||||
try {
|
||||
tenantRes = await getTenant(tenantClaim);
|
||||
const allTenants = await getTenants();
|
||||
if (Array.isArray(allTenants) && allTenants.length > 0) {
|
||||
const claimStr = tenantClaim ? String(tenantClaim).trim().toLowerCase() : '';
|
||||
const matched = claimStr
|
||||
? allTenants.find(
|
||||
(t) =>
|
||||
(t.name && t.name.trim().toLowerCase() === claimStr) ||
|
||||
String(t.id).toLowerCase() === claimStr ||
|
||||
encodeHashIdSync(t.id).toLowerCase() === claimStr
|
||||
)
|
||||
: null;
|
||||
|
||||
const targetTenant = matched || allTenants.find((t) => isStatusActive((t as any).status ?? (t as any).Status)) || allTenants[0];
|
||||
if (targetTenant && targetTenant.id !== undefined && targetTenant.id !== null) {
|
||||
try {
|
||||
const hashedTargetId = encodeHashIdSync(targetTenant.id);
|
||||
tenantRes = await getTenant(hashedTargetId || targetTenant.id);
|
||||
lastError = null;
|
||||
} catch {
|
||||
tenantRes = targetTenant;
|
||||
lastError = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!lastError) lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Final fallback: query default primary tenant ID 1 encoded as a HashId (matching ConsoleUI)
|
||||
if (!tenantRes && candidateId !== 1 && candidateId !== '1') {
|
||||
try {
|
||||
const hashedDefaultId = encodeHashIdSync(1);
|
||||
tenantRes = await getTenant(hashedDefaultId);
|
||||
lastError = null;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (!lastError) lastError = err;
|
||||
tenantRes = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Condition 1: If nothing is returned
|
||||
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
||||
const serverMsg =
|
||||
let serverMsg =
|
||||
lastError?.response?.data?.detail ||
|
||||
lastError?.response?.data?.message ||
|
||||
lastError?.message ||
|
||||
'No tenant record returned';
|
||||
|
||||
if (typeof serverMsg === 'string') {
|
||||
if (/^network\s*error$/i.test(serverMsg.trim())) {
|
||||
serverMsg = 'Permission Check';
|
||||
} else {
|
||||
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
|
||||
}
|
||||
}
|
||||
|
||||
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
|
||||
setActivationPending(true, errorMsg);
|
||||
return {
|
||||
@@ -145,10 +166,8 @@ export async function verifyTenantLockout(
|
||||
};
|
||||
}
|
||||
|
||||
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
|
||||
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
|
||||
|
||||
// Condition 2: If there is a response object and its Status is not Active
|
||||
const rawStatus =
|
||||
(tenantObj as any)?.status ??
|
||||
(tenantObj as any)?.Status ??
|
||||
@@ -159,7 +178,7 @@ export async function verifyTenantLockout(
|
||||
|
||||
if (!active) {
|
||||
const statusLabel = getStatusLabel(rawStatus);
|
||||
const tenantName = tenantObj?.name || String(primaryId);
|
||||
const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
|
||||
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
|
||||
setActivationPending(true, errorMsg);
|
||||
return {
|
||||
@@ -169,15 +188,12 @@ export async function verifyTenantLockout(
|
||||
};
|
||||
}
|
||||
|
||||
// Success: Response object returned and Status is Active
|
||||
setActivationPending(false);
|
||||
|
||||
const successResult: TenantLockoutResult = {
|
||||
isActive: true,
|
||||
tenant: tenantObj,
|
||||
};
|
||||
cachedTenantLockout = {
|
||||
key: cacheKey,
|
||||
result: successResult,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Direct return without caching
|
||||
return successResult;
|
||||
}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import Hashids from 'hashids';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
||||
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
||||
*/
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt:
|
||||
(env.VITE_HASHER_SALT as string) ||
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
};
|
||||
|
||||
// Singleton Hashids instance initialized with salt & minHashLength
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
|
||||
/**
|
||||
* Dynamically reconfigures the deterministic Hashids instance from runtime configuration.
|
||||
*/
|
||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||
let updated = false;
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||
*/
|
||||
function isAlreadyHashId(candidate: string): boolean {
|
||||
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(candidate);
|
||||
return Array.isArray(decoded) && decoded.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If already a number
|
||||
if (typeof id === 'number') {
|
||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
if (!str) return '';
|
||||
|
||||
// Check if string is already a valid HashID
|
||||
if (isAlreadyHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Check if string is pure digits (e.g. "123")
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Non-numeric identifiers (UUIDs, codes) returned as-is
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
if (typeof hashId === 'number') return hashId;
|
||||
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
// If already pure integer string
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? str : parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return raw string
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a HashID or numeric value strictly to a canonical integer.
|
||||
* Useful for request payloads expecting int64 integers.
|
||||
*/
|
||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||
if (typeof hashId === 'number') {
|
||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
}
|
||||
const str = String(hashId).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
} catch {}
|
||||
const fallback = Number(str);
|
||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||
}
|
||||
|
||||
export const toolsService = {
|
||||
/**
|
||||
* Synchronous encoding
|
||||
*/
|
||||
encodeIdSync(id: number | string | null | undefined): string {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronous decoding
|
||||
*/
|
||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronous decoding strictly to canonical integer
|
||||
*/
|
||||
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
return decodeToNumberSync(hashId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
|
||||
*/
|
||||
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Decodes a HashID string back to original identifier
|
||||
*/
|
||||
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Domain-specific convenience resolvers for entities
|
||||
*/
|
||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(projectId);
|
||||
},
|
||||
|
||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(organisationId);
|
||||
},
|
||||
|
||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(programmeId);
|
||||
},
|
||||
|
||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(portfolioId);
|
||||
},
|
||||
|
||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(awardId);
|
||||
},
|
||||
|
||||
async resolveContractId(contractId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(contractId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves query parameters or path variables encoded as HashIDs before performing API calls
|
||||
*/
|
||||
async resolveHashedParams<T extends Record<string, any>>(
|
||||
params: T,
|
||||
keysToHash: (keyof T)[]
|
||||
): Promise<T> {
|
||||
const resolved: any = { ...params };
|
||||
for (const key of keysToHash) {
|
||||
const val = params[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
resolved[key] = encodeHashIdSync(val);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
|
||||
/**
|
||||
* Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
|
||||
*/
|
||||
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
|
||||
// No-op - instant deterministic computation
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* React Hook for consuming HashIDs with TanStack Query caching in components
|
||||
*/
|
||||
export function useHashId(id?: number | string | null, context = 'general') {
|
||||
return useQuery({
|
||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
||||
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
||||
enabled: id !== undefined && id !== null && id !== '',
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -252,47 +252,22 @@ async function executeBackendSync(
|
||||
organisationId: resolvedOrgId,
|
||||
};
|
||||
|
||||
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
||||
|
||||
let lastError: any = null;
|
||||
|
||||
for (const endpoint of candidateEndpoints) {
|
||||
const endpoint = '/v1/api/integration/sync';
|
||||
try {
|
||||
let res;
|
||||
if (endpoint === '/v1/api/integration/sync') {
|
||||
try {
|
||||
res = await apiClient.post(endpoint, payload);
|
||||
} catch (postErr: any) {
|
||||
const errStatus = postErr?.response?.status;
|
||||
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
|
||||
res = await apiClient.post(endpoint, {});
|
||||
} else {
|
||||
throw postErr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res = await apiClient.post(endpoint, payload);
|
||||
}
|
||||
const res = await apiClient.post(endpoint, payload);
|
||||
return { endpoint, data: res?.data };
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
const status = err?.response?.status;
|
||||
|
||||
// Fail fast on token expiration/invalid
|
||||
if (status === 401) {
|
||||
if (status === 400 || status === 415 || status === 422) {
|
||||
try {
|
||||
const resFallback = await apiClient.post(endpoint, {});
|
||||
return { endpoint, data: resFallback?.data };
|
||||
} catch (fallbackErr) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// User blocked or pending activation
|
||||
if (status === 403 || status === 423) {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// If 404 or 405, continue to next fallback candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Failed to reach user sync endpoint');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,13 +328,21 @@ export async function syncUserAccount(
|
||||
|
||||
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
||||
clearSuccessfulSyncRecord();
|
||||
const serverMsg =
|
||||
let serverMsg =
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
'Account is not yet enabled by the administrator.';
|
||||
|
||||
if (typeof serverMsg === 'string') {
|
||||
if (/^network\s*error$/i.test(serverMsg.trim())) {
|
||||
serverMsg = 'Permission Check';
|
||||
} else {
|
||||
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
|
||||
}
|
||||
}
|
||||
|
||||
setActivationPending(true, serverMsg);
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,8 @@ export enum NotificationStatus {
|
||||
Failed = 7,
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
Archived = 11,
|
||||
}
|
||||
|
||||
export enum NotificationPlatform {
|
||||
@@ -213,6 +215,12 @@ export enum ApprovalStatus {
|
||||
PendingReview = 2,
|
||||
}
|
||||
|
||||
export enum ComplianceStatus {
|
||||
NotMet = 1,
|
||||
Met = 2,
|
||||
Exceeded = 3,
|
||||
}
|
||||
|
||||
export enum ActivityStatus {
|
||||
Active = 1,
|
||||
Inactive = 2,
|
||||
@@ -678,11 +686,13 @@ export interface CreateContractRequest {
|
||||
|
||||
export interface UpdateContractRequest {
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
status: ContractStatus;
|
||||
}
|
||||
|
||||
export interface ExecuteContractRequest {
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
externalSignatureId: string;
|
||||
signedDocumentUrl: string;
|
||||
}
|
||||
@@ -742,21 +752,26 @@ export interface Indicator {
|
||||
id: number | string;
|
||||
tenantId?: number | string;
|
||||
projectId: number | string;
|
||||
activityId?: number | string | null;
|
||||
name: string | null;
|
||||
unitOfMeasure?: UnitOfMeasure | null;
|
||||
baselineAmount: number;
|
||||
targetAmount: number;
|
||||
actualAmount: number;
|
||||
targetCost?: number;
|
||||
actualCost?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateIndicatorRequest {
|
||||
projectId: number | string;
|
||||
activityId?: number | string | null;
|
||||
name: string;
|
||||
unitOfMeasure: UnitOfMeasure;
|
||||
baselineAmount: number;
|
||||
targetAmount: number;
|
||||
targetCost?: number;
|
||||
}
|
||||
|
||||
export interface Risk {
|
||||
@@ -771,7 +786,10 @@ export interface Risk {
|
||||
createdAt: string;
|
||||
owner?: string;
|
||||
mitigationAction?: string;
|
||||
mitigation?: string | null;
|
||||
status?: string;
|
||||
category?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRiskRequest {
|
||||
@@ -823,6 +841,7 @@ export interface Invite {
|
||||
invitedOrganisationId: number | string;
|
||||
awardId: number | string;
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
recipient: string;
|
||||
createdBy: string;
|
||||
updatedBy?: string | null;
|
||||
@@ -837,11 +856,30 @@ export interface InviteRequest {
|
||||
invitedOrganisationId: number | string;
|
||||
awardId: number | string;
|
||||
contractId: number | string;
|
||||
projectId?: number | string;
|
||||
recipient: string;
|
||||
message: string;
|
||||
platform: NotificationPlatform;
|
||||
}
|
||||
|
||||
export interface CreateNotificationRequest {
|
||||
organisationId: number | string;
|
||||
recipient: string;
|
||||
platform: NotificationPlatform;
|
||||
subject?: string | null;
|
||||
destination?: string | null;
|
||||
message: string;
|
||||
status?: NotificationStatus;
|
||||
}
|
||||
|
||||
export interface UpdateNotificationRequest {
|
||||
notificationId: number;
|
||||
destination?: string | null;
|
||||
status: NotificationStatus;
|
||||
sentAt?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// New OpenAPI 3.1.1 Entities & Request Models
|
||||
// ==========================================
|
||||
@@ -906,6 +944,7 @@ export interface Outcome {
|
||||
beneficiaryId: number | string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
status?: ComplianceStatus | number;
|
||||
createdBy?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
@@ -914,12 +953,14 @@ export interface CreateOutcomeRequest {
|
||||
beneficiaryId: number | string;
|
||||
name: string;
|
||||
description: string;
|
||||
status?: ComplianceStatus | number;
|
||||
}
|
||||
|
||||
export interface UpdateOutcomeRequest {
|
||||
outcomeId: number | string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: ComplianceStatus | number;
|
||||
}
|
||||
|
||||
export interface ChangeRequest {
|
||||
@@ -957,9 +998,10 @@ export interface ComplianceItem {
|
||||
id: number | string;
|
||||
tenantId?: number | string;
|
||||
projectId: number | string;
|
||||
beneficiaryId?: number | string | null;
|
||||
title: string | null;
|
||||
requirements: string | null;
|
||||
status: ApprovalStatus;
|
||||
status: ComplianceStatus | ApprovalStatus | number;
|
||||
createdBy: string;
|
||||
verifiedBy?: string | null;
|
||||
createdAt: string;
|
||||
@@ -970,13 +1012,16 @@ export interface CreateComplianceItemRequest {
|
||||
projectId: number | string;
|
||||
title: string;
|
||||
requirements: string;
|
||||
status?: ComplianceStatus | number;
|
||||
beneficiaryId?: number | string | null;
|
||||
}
|
||||
|
||||
export interface UpdateComplianceItemRequest {
|
||||
complianceItemId: number | string;
|
||||
title: string;
|
||||
requirements: string;
|
||||
status: ApprovalStatus;
|
||||
status: ComplianceStatus | number;
|
||||
beneficiaryId?: number | string | null;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
|
||||
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
||||
} else {
|
||||
formatted = rawVal ?? '';
|
||||
}
|
||||
if (typeof formatted === 'string') {
|
||||
// Strip out line breaks and carriage returns that distort Excel row heights and layout
|
||||
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
rowObj[col.header] = formatted;
|
||||
});
|
||||
return rowObj;
|
||||
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
||||
} else {
|
||||
formatted = String(rawVal ?? '');
|
||||
}
|
||||
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return Math.max(max, formatted.length);
|
||||
}, headerLen);
|
||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
|
||||
});
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const DEFAULT_HASHER_SALT =
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt:
|
||||
(env.VITE_HASHER_SALT as string) ||
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
@@ -35,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
* Validates if candidate is already a HashID generated by this hasher.
|
||||
*/
|
||||
export function isHashId(candidate: string | null | undefined): boolean {
|
||||
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decodedNumbers = hasher.decode(candidate);
|
||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
||||
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(candidate);
|
||||
if (decodedHex && decodedHex.length > 0) return true;
|
||||
if (decodedHex && decodedHex.length > 0) {
|
||||
if (hasher.encodeHex(decodedHex) === candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -194,3 +209,132 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||
*/
|
||||
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||
return isHashId(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
|
||||
return encodeEntityId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
if (typeof hashId === 'number') return hashId;
|
||||
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? str : parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
const num = Number(BigInt('0x' + decodedHex));
|
||||
if (!isNaN(num)) return Math.trunc(num);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a HashID or numeric value strictly to a canonical integer.
|
||||
* Useful for request payloads expecting int64 integers.
|
||||
*/
|
||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||
if (typeof hashId === 'number') {
|
||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
}
|
||||
const str = String(hashId).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
const num = Number(BigInt('0x' + decodedHex));
|
||||
if (!isNaN(num)) return Math.trunc(num);
|
||||
}
|
||||
} catch {}
|
||||
const fallback = Number(str);
|
||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy service resolver facade for compatibility with API endpoints and monitoring tables.
|
||||
*/
|
||||
export const toolsService = {
|
||||
encodeIdSync(id: number | string | bigint | null | undefined): string {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
return decodeToNumberSync(hashId);
|
||||
},
|
||||
async encodeId(id: number | string | bigint | null | undefined): Promise<string> {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
async decodeId(hashId: string | number | null | undefined): Promise<number | string> {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(projectId);
|
||||
},
|
||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(organisationId);
|
||||
},
|
||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(programmeId);
|
||||
},
|
||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(portfolioId);
|
||||
},
|
||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(awardId);
|
||||
},
|
||||
async resolveContractId(contractId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(contractId);
|
||||
},
|
||||
async resolveHashedParams<T extends Record<string, any>>(
|
||||
params: T,
|
||||
keysToHash: (keyof T)[]
|
||||
): Promise<T> {
|
||||
const resolved: any = { ...params };
|
||||
for (const key of keysToHash) {
|
||||
const val = params[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
resolved[key] = encodeHashIdSync(val);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
async primeCache(): Promise<void> {},
|
||||
};
|
||||
|
||||
+2
-1
@@ -4,8 +4,9 @@ interface ImportMetaEnv {
|
||||
readonly VITE_OIDC_AUTHORITY?: string;
|
||||
readonly VITE_OIDC_AUDIENCE?: string;
|
||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||
readonly VITE_OIDC_CLIENT_SECRET?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_HASHER_SALT?: string;
|
||||
readonly VITE_HASHER_MIN_LENGTH?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineConfig(async ({ command }) => {
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
onwarn(warning, defaultHandler) {
|
||||
onwarn(warning: any, defaultHandler: any) {
|
||||
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
||||
if (
|
||||
warning.code === 'INVALID_ANNOTATION' &&
|
||||
@@ -61,7 +61,7 @@ export default defineConfig(async ({ command }) => {
|
||||
defaultHandler(warning);
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
manualChunks(id: string) {
|
||||
if (id.includes('node_modules')) {
|
||||
// 1. Heavy export and PDF document generation
|
||||
if (id.includes('xlsx')) {
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed class SmtpSettings
|
||||
public string? Host { get; set; }
|
||||
|
||||
public bool UseSsl { get; set; }
|
||||
|
||||
public string? ProcessorJobCron { get; set; }
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public class Activity : Models.Activity, ITenantEntity
|
||||
public virtual Milestone? Milestone { get; set; }
|
||||
|
||||
public virtual ICollection<Evidence> Evidences { get; set; } = [];
|
||||
|
||||
public virtual ICollection<Indicator> Indicators { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ActivityConfiguration : IEntityTypeConfiguration<Activity>
|
||||
|
||||
@@ -9,6 +9,8 @@ public class ComplianceItem : Models.ComplianceItem, ITenantEntity
|
||||
|
||||
public virtual Project? Project { get; set; }
|
||||
|
||||
public virtual Beneficiary? Beneficiary { get; set; }
|
||||
|
||||
public virtual User? Creator { get; set; }
|
||||
|
||||
public virtual User? Verifier { get; set; }
|
||||
@@ -23,6 +25,7 @@ public sealed class ComplianceItemConfiguration : IEntityTypeConfiguration<Compl
|
||||
builder.HasKey(pk => pk.Id);
|
||||
builder.Property(f => f.TenantId).IsRequired();
|
||||
builder.Property(f => f.ProjectId).IsRequired();
|
||||
builder.Property(f => f.BeneficiaryId).IsRequired(false);
|
||||
builder.Property(f => f.CreatedBy).IsRequired();
|
||||
builder.Property(f => f.VerifiedBy).IsRequired(false);
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
@@ -30,7 +33,7 @@ public sealed class ComplianceItemConfiguration : IEntityTypeConfiguration<Compl
|
||||
builder.Property(f => f.Title).IsRequired().HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.Requirements).IsRequired().HasMaxLength(MediumTextLength);
|
||||
builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("1")
|
||||
.HasSentinel(ApprovalStatus.Pending);
|
||||
.HasSentinel(ComplianceStatus.NotMet);
|
||||
|
||||
builder.HasOne(f => f.Project)
|
||||
.WithMany()
|
||||
@@ -38,6 +41,12 @@ public sealed class ComplianceItemConfiguration : IEntityTypeConfiguration<Compl
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(f => f.Beneficiary)
|
||||
.WithMany()
|
||||
.HasForeignKey(fk => fk.BeneficiaryId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(f => f.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(fk => fk.CreatedBy)
|
||||
|
||||
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
|
||||
builder.Property(f => f.IndicatorId).IsRequired();
|
||||
builder.Property(f => f.ActivityId).IsRequired();
|
||||
builder.Property(f => f.CreatedBy).IsRequired();
|
||||
builder.Property(f => f.UpdatedBy).IsRequired();
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.UpdatedBy).IsRequired(false);
|
||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(f => f.DocumentUrl).IsRequired();
|
||||
|
||||
@@ -9,6 +9,8 @@ public class Indicator : Models.Indicator, ITenantEntity
|
||||
|
||||
public virtual Project? Project { get; set; }
|
||||
|
||||
public virtual Activity? Activity { get; set; }
|
||||
|
||||
public virtual User? Creator { get; set; }
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
|
||||
builder.HasKey(pk => pk.Id);
|
||||
builder.Property(f => f.TenantId).IsRequired();
|
||||
builder.Property(f => f.ProjectId).IsRequired();
|
||||
builder.Property(f => f.ActivityId).IsRequired(false);
|
||||
builder.Property(f => f.CreatedBy).IsRequired();
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.Name).IsRequired().HasMaxLength(LabelLength);
|
||||
@@ -28,6 +31,8 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
|
||||
builder.Property(f => f.BaselineAmount).IsRequired().HasPrecision(18, 2);
|
||||
builder.Property(f => f.TargetAmount).IsRequired().HasPrecision(18, 2);
|
||||
builder.Property(f => f.ActualAmount).IsRequired().HasPrecision(18, 2);
|
||||
builder.Property(f => f.TargetCost).IsRequired().HasPrecision(18, 2).HasDefaultValueSql("0");
|
||||
builder.Property(f => f.ActualCost).IsRequired().HasPrecision(18, 2).HasDefaultValueSql("0");
|
||||
|
||||
builder.HasOne(f => f.Tenant)
|
||||
.WithMany()
|
||||
@@ -41,6 +46,12 @@ public sealed class IndicatorConfiguration : IEntityTypeConfiguration<Indicator>
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(f => f.Activity)
|
||||
.WithMany(a => a.Indicators)
|
||||
.HasForeignKey(fk => fk.ActivityId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(f => f.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(fk => fk.CreatedBy)
|
||||
|
||||
@@ -25,6 +25,8 @@ public sealed class OutcomeConfiguration : IEntityTypeConfiguration<Outcome>
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.Name).IsRequired().HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.Description).IsRequired().HasMaxLength(TextLength);
|
||||
builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("1")
|
||||
.HasSentinel(ComplianceStatus.NotMet);
|
||||
|
||||
builder.HasOne(f => f.Tenant)
|
||||
.WithMany()
|
||||
|
||||
@@ -23,6 +23,8 @@ public enum NotificationStatus : int
|
||||
Failed = 7,
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
Archived = 11,
|
||||
}
|
||||
|
||||
public enum NotificationPlatform : int
|
||||
@@ -198,3 +200,10 @@ public enum ActivityStatus : int
|
||||
Inactive = 2,
|
||||
Disabled = 3,
|
||||
}
|
||||
|
||||
public enum ComplianceStatus : int
|
||||
{
|
||||
NotMet = 1,
|
||||
Met = 2,
|
||||
Exceeded = 3,
|
||||
}
|
||||
|
||||
@@ -27,5 +27,5 @@ public static class Constants
|
||||
public const int ShortLabelLength = 100;
|
||||
public const int TextLength = 1024;
|
||||
public const int MediumTextLength = 2048;
|
||||
public const int LargeTextLength = 4096;
|
||||
public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
|
||||
}
|
||||
@@ -137,6 +137,7 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
ProjectId = entity.ProjectId,
|
||||
BeneficiaryId = entity.BeneficiaryId,
|
||||
Requirements = entity.Requirements,
|
||||
Status = entity.Status,
|
||||
Title = entity.Title,
|
||||
@@ -201,7 +202,10 @@ public static class Mappers
|
||||
CreatedBy = entity.CreatedBy,
|
||||
Name = entity.Name,
|
||||
ProjectId = entity.ProjectId,
|
||||
ActivityId = entity.ActivityId,
|
||||
TargetAmount = entity.TargetAmount,
|
||||
TargetCost = entity.TargetCost,
|
||||
ActualCost = entity.ActualCost,
|
||||
UnitOfMeasure = entity.UnitOfMeasure
|
||||
};
|
||||
|
||||
@@ -285,7 +289,8 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
Description = entity.Description,
|
||||
Name = entity.Name
|
||||
Name = entity.Name,
|
||||
Status = entity.Status
|
||||
};
|
||||
|
||||
public static Portfolio Map(this Entities.Portfolio entity) => new()
|
||||
|
||||
@@ -11,8 +11,8 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea
|
||||
if (scheduler == null)
|
||||
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
|
||||
|
||||
if (scheduler.Status != SchedulerStatus.Running)
|
||||
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running");
|
||||
if (scheduler.Status is not (SchedulerStatus.Running or SchedulerStatus.Standby or SchedulerStatus.Created))
|
||||
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not available (Status: {scheduler.Status})");
|
||||
|
||||
await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ public class ComplianceItem
|
||||
|
||||
public long ProjectId { get; set; }
|
||||
|
||||
public long? BeneficiaryId { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Requirements { get; set; }
|
||||
|
||||
public ApprovalStatus Status { get; set; }
|
||||
public ComplianceStatus Status { get; set; } = ComplianceStatus.NotMet;
|
||||
|
||||
public Guid CreatedBy { get; set; }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user