13 Commits
Author SHA1 Message Date
hermes 2d0f2b494a Test 2 reoslutions 2026-09-20 15:25:53 +02:00
hermes d428f6e17c Remediated test #1 results 2026-09-20 13:51:01 +02:00
hermes 5829bffaa0 Refactored UI endpoint usage 2026-09-20 08:26:06 +02:00
hermes b703b0c64d Added service cache feature flag
Updated scheduler and moved to its own dedicated database
2026-09-18 16:11:14 +02:00
hermes 6d554d19a7 Implemented featureflag for service layer caching 2026-09-18 14:58:42 +02:00
hermes ec777fabf8 Implemented session and account lockout management
Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
2026-09-18 10:19:24 +02:00
hermes 41bf9b63bf Added dist/assets/ to gitignore 2026-09-17 07:38:52 +02:00
hermes 940630f0ed Untracking dist files 2026-09-17 07:38:16 +02:00
hermes 6de3d30a8b Removed package.json 2026-09-17 07:23:18 +02:00
hermes 831d57a8a0 Refactored UI and compose file 2026-09-17 07:07:05 +02:00
hermes 2d74f7f1bd Removed docker debug code 2026-09-15 12:13:16 +02:00
hermes ebf35d7f12 Refactored ApiVersioning service registration 2026-09-15 12:09:10 +02:00
hermes 77307f4bf0 Added OPTIONS header handling 2026-09-15 08:51:48 +02:00
80 changed files with 34863 additions and 11882 deletions
+11 -2
View File
@@ -11,6 +11,7 @@
*.sln.docstates *.sln.docstates
*.env *.env
*.env.json *.env.json
certificates/
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs
@@ -51,6 +52,8 @@ bld/
# Build results on 'Bin' directories # Build results on 'Bin' directories
**/[Bb]in/* **/[Bb]in/*
dist/assets/
dist/
# Uncomment if you have tasks that rely on *.refresh files to move binaries # Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736) # (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh #!**/[Bb]in/*.refresh
@@ -58,8 +61,7 @@ bld/
# Visual Studio 2015/2017 cache/options directory # Visual Studio 2015/2017 cache/options directory
.vs/ .vs/
.vscode/ .vscode/
bin/
certificates/
# Uncomment if you have tasks that create the project's static files in wwwroot # Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/ #wwwroot/
@@ -347,6 +349,7 @@ _Pvt_Extensions
# Paket dependency manager # Paket dependency manager
**/.paket/paket.exe **/.paket/paket.exe
**/.paket/paket.exe
paket-files/ paket-files/
# FAKE - F# Make # FAKE - F# Make
@@ -432,3 +435,9 @@ FodyWeavers.xsd
*.msix *.msix
*.msm *.msm
*.msp *.msp
/PostFundManagement.ConsoleUI/dist/config.json
/PostFundManagement.ConsoleUI/dist/env-config.js
/PostFundManagement.ConsoleUI/dist/assets/index-BaOzkVJi.css
/PostFundManagement.ConsoleUI/dist/assets/index-BU8qSe7k.js
/PostFundManagement.ConsoleUI/dist/favicon.svg
/PostFundManagement.ConsoleUI/dist/index.html
+1 -1
View File
@@ -22,7 +22,7 @@ COPY . .
# Build from the project folder using relative filename # Build from the project folder using relative filename
WORKDIR "/src/PostFundManagement.Api" WORKDIR "/src/PostFundManagement.Api"
RUN dotnet build "PostFundManagement.Api.csproj" -c Release -o /app/build -v n RUN dotnet build "PostFundManagement.Api.csproj" -c Release -o /app/build
# Stage 3: Publish # Stage 3: Publish
FROM build AS publish FROM build AS publish
@@ -25,8 +25,8 @@
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" /> <PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="KubernetesClient" Version="19.0.2" /> <PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<Using Include="Microsoft.AspNetCore.Authentication" /> <Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" /> <Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
@@ -60,8 +60,8 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" /> <Using Include="Microsoft.OpenApi" />
+31 -7
View File
@@ -1,16 +1,31 @@
using Microsoft.FeatureManagement;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Filters; using PostFundManagement.Application.Filters;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator; using PostFundManagement.Domain.Mediator;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer() builder.Services.AddFeatureManagement();
.AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader());
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("x-api-version"),
new MediaTypeApiVersionReader("version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddApiVersioning().AddApiExplorer();
builder.Services.AddScoped<ActiveUserFilter>(); builder.Services.AddScoped<ActiveUserFilter>();
builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
@@ -107,9 +122,9 @@ builder.Services.AddCors(options =>
var app = builder.Build(); var app = builder.Build();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>(); var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName); var scheduler = await schedulerFactory.GetScheduler();
if (!scheduler!.IsStarted) if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start(); await scheduler.Start();
app.UseHsts(); app.UseHsts();
@@ -130,7 +145,16 @@ var versionGroups = new Dictionary<int, RouteGroupBuilder>
{ {
{ {
1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet)
.RequireCors(CorsPolicy).AddEndpointFilter<ActiveUserFilter>() .RequireCors(CorsPolicy)
.AddEndpointFilter(async (context, next) =>
{
if (HttpMethods.IsOptions(context.HttpContext.Request.Method))
return await next(context);
var filter = context.HttpContext.RequestServices.GetRequiredService<ActiveUserFilter>();
return await filter.InvokeAsync(context, next);
})
} }
}; };
+1
View File
@@ -1,4 +1,5 @@
{ {
"EnableServiceCaching": true,
"CorsOrigins": [ "CorsOrigins": [
"https://console.ifri.khongisa.co.za", "https://console.ifri.khongisa.co.za",
"https://localhost:7208" "https://localhost:7208"
@@ -2,7 +2,7 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Quartz; using PostFundManagement.Domain.Quartz;
using static PostFundManagement.Domain.Extensions.Constants; using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Domain.Extensions; namespace PostFundManagement.Application.Extensions;
public static class Quartz public static class Quartz
{ {
@@ -10,29 +10,27 @@ public static class Quartz
{ {
var connectionString = configuration.GetConnectionString(DatabaseConfigName); var connectionString = configuration.GetConnectionString(DatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
options.Properties["quartz.scheduler.instanceName"] = schedulerName;
options.Properties["quartz.scheduler.instanceId"] = "AUTO";
});
services.ConfigureCommon(); services.ConfigureCommon();
services.AddQuartz(config => services.AddQuartz(config =>
{ {
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.UseSimpleTypeLoader(); config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0); config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
config.UseTimeZoneConverter(); config.UseTimeZoneConverter();
config.UsePersistentStore(storage => config.UsePersistentStore(storage =>
{ {
storage.PerformSchemaValidation = false; storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer(); storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster => storage.UseClustering(cluster =>
{ {
cluster.CheckinInterval = TimeSpan.FromSeconds(30); cluster.CheckinInterval = TimeSpan.FromSeconds(30);
@@ -48,36 +46,37 @@ public static class Quartz
{ {
var connectionString = configuration.GetConnectionString(DatabaseConfigName); var connectionString = configuration.GetConnectionString(DatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
options.Properties["quartz.scheduler.instanceName"] = schedulerName;
options.Properties["quartz.scheduler.instanceId"] = "AUTO";
options.Properties["quartz.scheduler.batchTriggerAcquisitionMaxCount"] = "5";
options.Properties["quartz.jobStore.misfireThreshold"] = TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
options.Properties["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
options.Properties["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
});
services.ConfigureCommon(); services.ConfigureCommon();
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.AddQuartz(config => services.AddQuartz(config =>
{ {
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.InterruptJobsOnShutdown = true;
config.InterruptJobsOnShutdownWithWait = true;
config.MaxBatchSize = 5;
config.UseSimpleTypeLoader(); config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1); config.UseDefaultThreadPool(options => options.MaxConcurrency = 5);
config.UseTimeZoneConverter(); config.UseTimeZoneConverter();
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture));
config.UsePersistentStore(storage => config.UsePersistentStore(storage =>
{ {
storage.PerformSchemaValidation = false; storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer(); storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster => storage.UseClustering(cluster =>
{ {
cluster.CheckinInterval = TimeSpan.FromSeconds(30); cluster.CheckinInterval = TimeSpan.FromSeconds(30);
@@ -91,15 +90,7 @@ public static class Quartz
private static IServiceCollection ConfigureCommon(this IServiceCollection services) private static IServiceCollection ConfigureCommon(this IServiceCollection services)
{ {
services.Configure<QuartzOptions>(options => services.Configure<QuartzOptions>(options => options.Scheduling.IgnoreDuplicates = true);
{
options.Scheduling.IgnoreDuplicates = true;
options.Scheduling.OverWriteExistingData = true;
options["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
options["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
});
services.AddTransient<RetryJobListener>(); services.AddTransient<RetryJobListener>();
services.AddTransient<IJobOrchestrator, JobOrchestrator>(); services.AddTransient<IJobOrchestrator, JobOrchestrator>();
@@ -24,9 +24,9 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" /> <Using Include="Microsoft.OpenApi" />
@@ -36,6 +36,7 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Humanizer" /> <Using Include="Humanizer" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.ComponentModel.DataAnnotations" /> <Using Include="System.ComponentModel.DataAnnotations" />
<Using Include="Microsoft.Extensions.Caching.Distributed" /> <Using Include="Microsoft.Extensions.Caching.Distributed" />
<Using Include="Microsoft.Extensions.Caching.Hybrid" /> <Using Include="Microsoft.Extensions.Caching.Hybrid" />
@@ -4,14 +4,19 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Communications; namespace PostFundManagement.Application.Services.Communications;
public sealed class CommunicationsCacheService(CommunicationsService service, IDistributedCache cache) : ServiceCacheBase, ICommunicationsService public sealed class CommunicationsCacheService(CommunicationsService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, ICommunicationsService
{ {
public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite>> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInviteAsync(applicantUserId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value; var cacheKey = HashService.ToMd5Hash($"getapplicantinvite-{applicantUserId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -31,11 +36,14 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite[]>> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value; var cacheKey = HashService.ToMd5Hash($"getapplicantinvites-{applicantUserId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken); var result = await service.GetApplicantInvitesAsync(applicantUserId, cancellationToken);
@@ -52,6 +60,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Invite[]>> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Invite[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInvitesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getinvites-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Evidence; namespace PostFundManagement.Application.Services.Evidence;
public sealed class EvidenceCacheService(EvidenceService service, IDistributedCache cache) : ServiceCacheBase, IEvidenceService public sealed class EvidenceCacheService(EvidenceService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IEvidenceService
{ {
public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) => public async ValueTask<Result> CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) =>
await service.CreateEvidenceAsync(userId, form, file, cancellationToken); await service.CreateEvidenceAsync(userId, form, file, cancellationToken);
@@ -21,6 +23,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence>> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceAsync(evidenceId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value; var cacheKey = HashService.ToMd5Hash($"getevidence-{evidenceId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -42,6 +47,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceFilesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getevidencefiles-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -63,6 +71,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<string>> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () => await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getevidenceurl-{evidenceIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getevidenceurl-{evidenceIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,6 +95,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) => public async ValueTask<Result<Indicator>> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () => await Result.Try((Func<ValueTask<Result<Indicator>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorAsync(indicatorId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value; var cacheKey = HashService.ToMd5Hash($"getindicator-{indicatorId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -105,6 +119,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetIndicatorsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getindicators-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -126,11 +143,14 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getmilestoneevidences-{milestoneIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken); var result = await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken);
@@ -147,11 +167,14 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Evidence[]>> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Evidence[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprojectevidences-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken); var result = await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Execution; namespace PostFundManagement.Application.Services.Execution;
public sealed class ExecutionCacheService(ExecutionService service, IDistributedCache cache) : ServiceCacheBase, IExecutionService public sealed class ExecutionCacheService(ExecutionService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IExecutionService
{ {
public async ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) => public async ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) =>
await service.ApproveDisbursementAsync(disbursementIdHash, userId, cancellationToken); await service.ApproveDisbursementAsync(disbursementIdHash, userId, cancellationToken);
@@ -21,6 +23,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Disbursement>> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () => await Result.Try((Func<ValueTask<Result<Disbursement>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementAsync(disbursementIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getdisbursement-{disbursementIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -42,6 +47,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Disbursement[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDisbursementsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getdisbursements-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -63,6 +71,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Risk>> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk>>>)(async () => await Result.Try((Func<ValueTask<Result<Risk>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRiskAsync(riskIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getrisk-{riskIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,6 +95,9 @@ public sealed class ExecutionCacheService(ExecutionService service, IDistributed
public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetRisksAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getrisks-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Governance; namespace PostFundManagement.Application.Services.Governance;
public sealed class GovernanceCacheService(GovernanceService service, IDistributedCache cache) : ServiceCacheBase, IGovernanceService public sealed class GovernanceCacheService(GovernanceService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IGovernanceService
{ {
public async ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) => public async ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateOrganisationAsync(request, userId, cancellationToken); await service.CreateOrganisationAsync(request, userId, cancellationToken);
@@ -18,6 +20,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) => public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () => await Result.Try((Func<ValueTask<Result<Dashboard>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetDashboardAsync(cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getdashboard").Value; var cacheKey = HashService.ToMd5Hash($"getdashboard").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -39,6 +44,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Instrument[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetInstrumentsAsync(programmeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getinstruments").Value; var cacheKey = HashService.ToMd5Hash($"getinstruments").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -60,6 +68,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Organisation>> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () => await Result.Try((Func<ValueTask<Result<Organisation>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getorganisation-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -81,6 +92,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Organisation[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetOrganisationsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getorganisations-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -102,6 +116,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Portfolio[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPorfoliosAsync(organisationIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolios-{organisationIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolios-{organisationIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -123,11 +140,14 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Portfolio>> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () => await Result.Try((Func<ValueTask<Result<Portfolio>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolio-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetPortfolioAsync(portfolioIdHash, cancellationToken); var result = await service.GetPortfolioAsync(portfolioIdHash, cancellationToken);
@@ -144,11 +164,14 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getportfolioprogrammes-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken); var result = await service.GetPortfolioProgrammesAsync(portfolioIdHash, cancellationToken);
@@ -165,6 +188,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammesAsync(portfolioIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprogrammes-{portfolioIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Grants; namespace PostFundManagement.Application.Services.Grants;
public sealed class GrantsCacheService(GrantsService service, IDistributedCache cache) : ServiceCacheBase, IGrantsService public sealed class GrantsCacheService(GrantsService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IGrantsService
{ {
public async ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => public async ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateAwardAsync(request, userId, cancellationToken); await service.CreateAwardAsync(request, userId, cancellationToken);
@@ -24,6 +26,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Award>> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award>>>)(async () => await Result.Try((Func<ValueTask<Result<Award>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardAsync(awardIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getaward-{awardIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -45,6 +50,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Award[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetAwardsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getawards-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -66,6 +74,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Budget>> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget>>>)(async () => await Result.Try((Func<ValueTask<Result<Budget>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetAsync(budgetIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getbudget-{budgetIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -87,6 +98,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Budget[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetBudgetsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value; var cacheKey = HashService.ToMd5Hash($"getbudgets-{pagination.Page}-{pagination.PageSize}-{pagination.Index}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -108,6 +122,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Contract>> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract>>>)(async () => await Result.Try((Func<ValueTask<Result<Contract>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractAsync(contractIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getctract-{contractIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -129,6 +146,9 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Contract[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value; var cacheKey = HashService.ToMd5Hash($"getcontracts-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -150,15 +170,18 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache
public async ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<string>> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () => await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetContractUrlAsync(contractIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getcontracturl-{contractIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getcontracturl-{contractIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetContractUrlAsync(contractIdHash, cancellationToken); var result = await service.GetContractUrlAsync(contractIdHash, cancellationToken);
if(result.IsFailed) return result; if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value, cancellationToken); await cache.SetStringAsync(cacheKey, result.Value, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Projects; namespace PostFundManagement.Application.Services.Projects;
public sealed class ProjectCacheService(ProjectService service, IDistributedCache cache) : ServiceCacheBase, IProjectService public sealed class ProjectCacheService(ProjectService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IProjectService
{ {
public async ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) => public async ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken); await service.CreateActivityAsync(request, userId, cancellationToken);
@@ -21,6 +23,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Activity[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetActivitiesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getactivities-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getactivities-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -42,11 +47,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Domain.Models.Activity>> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Models.Activity>>>)(async () => await Result.Try((Func<ValueTask<Result<Domain.Models.Activity>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetActivityAsync(activityIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getactivity-{activityIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getactivity-{activityIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetActivityAsync(activityIdHash, cancellationToken); var result = await service.GetActivityAsync(activityIdHash, cancellationToken);
@@ -63,6 +71,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Milestone>> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone>>>)(async () => await Result.Try((Func<ValueTask<Result<Milestone>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestoneAsync(milestoneIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getmilestone-{milestoneIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getmilestone-{milestoneIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,6 +95,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetMilestonesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getmilestones-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value; var cacheKey = HashService.ToMd5Hash($"getmilestones-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -105,11 +119,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammeAsync(programmeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogramme-{programmeIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprogramme-{programmeIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProgrammeAsync(programmeIdHash, cancellationToken); var result = await service.GetProgrammeAsync(programmeIdHash, cancellationToken);
@@ -126,11 +143,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Project[]>> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogrammeprojects-{programmeIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprogrammeprojects-{programmeIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken); var result = await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken);
@@ -147,11 +167,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Programme[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProgrammesAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprogrammes-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value; var cacheKey = HashService.ToMd5Hash($"getprogrammes-{pagination.Index}-{pagination.PageSize}-{pagination.Page}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProgrammesAsync(pagination, cancellationToken); var result = await service.GetProgrammesAsync(pagination, cancellationToken);
@@ -168,6 +191,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Project>> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project>>>)(async () => await Result.Try((Func<ValueTask<Result<Project>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getproject-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getproject-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -189,6 +215,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Indicator[]>> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Indicator[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectindicators-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprojectindicators-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -197,7 +226,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{ {
var result = await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken); var result = await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken);
if(result.IsFailed) return result; if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken); await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
@@ -210,15 +239,18 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Milestone[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectmilestones-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprojectmilestones-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached)) if (string.IsNullOrWhiteSpace(cached))
{ {
var result = await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken); var result = await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken);
if(result.IsFailed) return result; if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken); await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
@@ -231,6 +263,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Risk[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectRisksAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectrisks-{projectIdHash}").Value; var cacheKey = HashService.ToMd5Hash($"getprojectrisks-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -239,7 +274,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{ {
var result = await service.GetProjectRisksAsync(projectIdHash, cancellationToken); var result = await service.GetProjectRisksAsync(projectIdHash, cancellationToken);
if(result.IsFailed) return result; if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken); await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
@@ -252,6 +287,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) => public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () => await Result.Try((Func<ValueTask<Result<Project[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectsAsync(pagination, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojects-{pagination.Index}-{pagination.Page}-{pagination.PageSize}").Value; var cacheKey = HashService.ToMd5Hash($"getprojects-{pagination.Index}-{pagination.Page}-{pagination.PageSize}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -260,7 +298,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{ {
var result = await service.GetProjectsAsync(pagination, cancellationToken); var result = await service.GetProjectsAsync(pagination, cancellationToken);
if(result.IsFailed) return result; if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken); await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
@@ -1,14 +1,19 @@
using PostFundManagement.Application.Abstractions; using PostFundManagement.Application.Abstractions;
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Tools; namespace PostFundManagement.Application.Services.Tools;
public sealed class ToolCacheService(ToolService service, IDistributedCache cache) : ServiceCacheBase, IToolService public sealed class ToolCacheService(ToolService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IToolService
{ {
public async ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken) => public async ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () => await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.EncodeLong(id, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"encodelonghash-{id}").Value; var cacheKey = HashService.ToMd5Hash($"encodelonghash-{id}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,14 +4,19 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Users; namespace PostFundManagement.Application.Services.Users;
public sealed class UserCacheService(UserService service, IDistributedCache cache) : ServiceCacheBase, IUserService public sealed class UserCacheService(UserService service, IDistributedCache cache,
IFeatureManager featureManager) : ServiceCacheBase, IUserService
{ {
public async ValueTask<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) => public async ValueTask<Result<User>> GetUserAsync(Guid userId, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<User>>>)(async () => await Result.Try((Func<ValueTask<Result<User>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetUserAsync(userId, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getuser-{userId}").Value; var cacheKey = HashService.ToMd5Hash($"getuser-{userId}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken); var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -33,6 +38,9 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
public async ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) => public async ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<User[]>>>)(async () => await Result.Try((Func<ValueTask<Result<User[]>>>)(async () =>
{ {
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetUsersAsync(pagination, request, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" + var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" +
$"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value; $"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value;
+10
View File
@@ -16,3 +16,13 @@ VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f02
# PFM Backend API Base URL # PFM Backend API Base URL
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/" VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
# Protocol / SSL Settings for local development
# HTTPS is enabled by default when running locally via @vitejs/plugin-basic-ssl.
# Set to 'false' if you ever need to run local dev over plain HTTP.
HTTPS="true"
VITE_ENABLE_HTTPS="true"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b2519d4f-e797-44fe-be2e-0d30bec6814d
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
version: '3.8'
services:
pfm-console:
build:
context: .
dockerfile: Dockerfile
container_name: pfm-console
ports:
- "8076:3000"
environment:
# OIDC Identity Provider Configuration
- OIDC_AUTHORITY=${OIDC_AUTHORITY}
- OIDC_AUDIENCE=${OIDC_AUDIENCE}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
# Backend API Configuration
- API_BASE_URL=${API_BASE_URL}
restart: unless-stopped
@@ -1,4 +1,102 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Target directory for Nginx static assets in container
TARGET_DIR="/usr/share/nginx/html"
# If not in standard nginx container, fallback to ./dist or ./public if present
if [ ! -d "$TARGET_DIR" ]; then
if [ -d "./dist" ]; then
TARGET_DIR="./dist"
elif [ -d "./public" ]; then
TARGET_DIR="./public"
fi
fi
# Prioritize deployment environment variables from all common naming patterns:
# 1. API Base URL
API_BASE_URL="${VITE_API_BASE_URL:-${API_BASE_URL:-${ApiSettings__BaseUrl:-${ApiSettings_BaseUrl:-${PFM_API_BASE_URL:-}}}}}"
# 2. OIDC Security Authority
OIDC_AUTHORITY="${VITE_OIDC_AUTHORITY:-${OIDC_AUTHORITY:-${SecuritySettings__Authority:-${SecuritySettings_Authority:-${AUTHORITY:-}}}}}"
# 3. OIDC Audience
OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audience:-${SecuritySettings_Audience:-${AUDIENCE:-}}}}}"
# 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
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:-}}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
cat <<EOF > "$TARGET_DIR/env-config.js"
// Generated dynamically at container startup from deployment environment variables
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}",
OIDC_AUTHORITY: "${OIDC_AUTHORITY}",
VITE_OIDC_AUTHORITY: "${OIDC_AUTHORITY}",
OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
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}",
ApiSettings: {
BaseUrl: "${API_BASE_URL}"
},
SecuritySettings: {
Authority: "${OIDC_AUTHORITY}",
Audience: "${OIDC_AUDIENCE}",
ClientId: "${OIDC_CLIENT_ID}",
ClientSecret: "${OIDC_CLIENT_SECRET}",
Scope: "${OIDC_SCOPE}"
},
HasherSettings: {
Salt: "${HASHER_SALT}",
MinHashLength: "${HASHER_MIN_LENGTH}"
}
};
EOF
# Generate config.json for REST-based runtime retrieval
cat <<EOF > "$TARGET_DIR/config.json"
{
"ApiSettings": {
"BaseUrl": "${API_BASE_URL}"
},
"SecuritySettings": {
"Authority": "${OIDC_AUTHORITY}",
"Audience": "${OIDC_AUDIENCE}",
"ClientId": "${OIDC_CLIENT_ID}",
"ClientSecret": "${OIDC_CLIENT_SECRET}",
"Scope": "${OIDC_SCOPE}"
},
"HasherSettings": {
"Salt": "${HASHER_SALT}",
"MinHashLength": "${HASHER_MIN_LENGTH}"
}
}
EOF
echo "[docker-entrypoint] Successfully wrote $TARGET_DIR/env-config.js and $TARGET_DIR/config.json."
fi
exec "$@" exec "$@"
+106
View File
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<!-- Ambient Radial Glow -->
<radialGradient id="pfm-ambient-glow" 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>
<!-- Top facet: Governance & Portfolios (Indigo / Radiant Violet) -->
<linearGradient id="pfm-grad-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>
<!-- Left facet: Capital & Disbursement Vault (Electric Sapphire) -->
<linearGradient id="pfm-grad-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>
<!-- Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) -->
<linearGradient id="pfm-grad-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>
<!-- Center Radiant Diamond -->
<linearGradient id="pfm-grad-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>
<!-- Soft outer glow filter -->
<filter id="pfm-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Deep Contrast Foundation Disk for Dark & Light browser tabs -->
<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-glow)" />
<!-- Outer Hexagonal Structural Shield Outline -->
<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"
/>
<!-- Facet 1: Top Canopy (Governance / Oversight Architecture) -->
<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-grad-top)"
/>
<!-- Facet 2: Left Vault (Fund Management & Capital Allocation) -->
<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-grad-left)"
/>
<!-- Facet 3: Right Wing (Milestone Performance & Impact Assurance) -->
<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-grad-right)"
/>
<!-- Center Geometric Hub (The PFM Nexus Plate) -->
<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"
/>
<!-- Kinetic Stepped Prisms -->
<path
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
fill="url(#pfm-grad-top)"
opacity="0.9"
/>
<path
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
fill="url(#pfm-grad-right)"
opacity="0.9"
/>
<!-- Center Radiant Milestone Diamond -->
<polygon
points="32,27 36.5,32 32,37 27.5,32"
fill="url(#pfm-grad-core)"
filter="url(#pfm-glow)"
/>
<!-- Center Pure White Optical Star Point -->
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+12 -4
View File
@@ -1,6 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Post Fund Management Console</title> <title>Post Fund Management Console</title>
@@ -9,10 +9,18 @@
<meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." /> <meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
</head> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<body> <link rel="alternate icon" href="/favicon.svg" />
<link rel="mask-icon" href="/favicon.svg" color="#2563eb" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div> <div id="root"></div>
<!-- Runtime Environment Configuration (Populated dynamically during deployment) -->
<script src="/env-config.js"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,5 +2,5 @@
"name": "Post Fund Management Console", "name": "Post Fund Management Console",
"description": "Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows.", "description": "Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows.",
"requestFramePermissions": [], "requestFramePermissions": [],
"majorCapabilities": [ "MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API" ] "majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
} }
+9 -9
View File
@@ -12,17 +12,17 @@ server {
gzip_proxied expired no-cache no-store private auth; gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/json image/svg+xml; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/json image/svg+xml;
# Cache control for static assets # Never cache dynamic runtime configuration files or index.html
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { location ~* ^/(?:config\.json|env-config\.js|index\.html)$ {
expires 1y; expires -1;
access_log off; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Cache-Control "public";
} }
# Disable caching for appsettings.json so changes take effect immediately # Cache control for static assets (CSS, JS, fonts, images)
location = /appsettings.json { location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff|woff2|ttf|eot)$ {
expires -1; expires 1y;
add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0"; access_log off;
add_header Cache-Control "public, immutable";
} }
# HTML5 SPA History Mode Support # HTML5 SPA History Mode Support
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,434 @@
=== ALL SCHEMAS WITH PROPERTIES ===
Model: Activity
Required: []
id: integer,string (format: int64, nullable: false)
projectId: null,integer,string (format: int64, nullable: false)
milestoneId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
category: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
title: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
performedAt: null,string (format: date-time, nullable: false)
Model: Award
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
projectId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
Model: Budget
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
Model: Contract
Required: []
id: integer,string (format: int64, nullable: false)
awardId: integer,string (format: int64, nullable: false)
externalSignatureId: null,string (format: -, nullable: false)
signedDocumentUrl: null,string (format: -, nullable: false)
effectiveAt: null,string (format: date-time, nullable: false)
expiresAt: null,string (format: date-time, nullable: false)
totalValue: number,string (format: double, nullable: false)
status: ContractStatus (format: -, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
Model: CreateActivityRequest
Required: [
'projectId',
'milestoneId',
'category',
'amount',
'title',
'description',
'performedAt'
]
projectId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
category*: string (format: -, nullable: false)
amount*: number,string (format: double, nullable: false)
title*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
performedAt*: null,string (format: date-time, nullable: false)
Model: CreateAwardRequest
Required: [ 'organisationId', 'programmeId', 'amount' ]
organisationId*: integer,string (format: int64, nullable: false)
programmeId*: integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateBudgetRequest
Required: [ 'projectId', 'name', 'amount' ]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateContractRequest
Required: [ 'awardId', 'totalValue', 'effectiveAt', 'expiresAt' ]
awardId*: integer,string (format: int64, nullable: false)
totalValue*: number,string (format: double, nullable: false)
effectiveAt*: string (format: date-time, nullable: false)
expiresAt*: string (format: date-time, nullable: false)
Model: CreateDisbursementRequest
Required: [ 'projectId', 'milestoneId', 'amount' ]
projectId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateIndicatorRequest
Required: [
'projectId',
'name',
'unitOfMeasure',
'baselineAmount',
'targetAmount'
]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
unitOfMeasure*: UnitOfMeasure (format: -, nullable: false)
baselineAmount*: number,string (format: double, nullable: false)
targetAmount*: number,string (format: double, nullable: false)
Model: CreateMilestoneRequest
Required: [ 'projectId', 'dueAt', 'name', 'priority' ]
projectId*: integer,string (format: int64, nullable: false)
dueAt*: string (format: date-time, nullable: false)
name*: string (format: -, nullable: false)
priority*: Priority (format: -, nullable: false)
Model: CreateOrganisationRequest
Required: [ 'name', 'registrationNo', 'email' ]
name*: string (format: -, nullable: false)
registrationNo*: string (format: -, nullable: false)
email*: string (format: -, nullable: false)
type: OrganisationType (format: -, nullable: false)
Model: CreatePortfolioRequest
Required: [ 'name', 'description', 'organisationId', 'ownerId' ]
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
organisationId*: integer,string (format: int64, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: CreateProgrammeRequest
Required: [ 'portfolioId', 'name', 'ownerId' ]
portfolioId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: CreateProjectRequest
Required: [ 'programmeId', 'name', 'description', 'sector', 'projectType' ]
programmeId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
sector*: IndustrySector (format: -, nullable: false)
projectType*: ProjectType (format: -, nullable: false)
Model: CreateRiskRequest
Required: [ 'projectId', 'likelihood', 'impact', 'name', 'description' ]
projectId*: integer,string (format: int64, nullable: false)
likelihood*: RiskLikelihood (format: -, nullable: false)
impact*: RiskImpact (format: -, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
Model: Dashboard
Required: [
'portfolioValue',
'activeAwards',
'disbursed',
'onTrackPercentage',
'atRiskCount',
'beneficiariesReached'
]
portfolioValue*: number,string (format: double, nullable: false)
activeAwards*: integer,string (format: int32, nullable: false)
disbursed*: number,string (format: double, nullable: false)
onTrackPercentage*: number,string (format: double, nullable: false)
atRiskCount*: integer,string (format: int32, nullable: false)
beneficiariesReached*: integer,string (format: int32, nullable: false)
Model: Disbursement
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
milestoneId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
amount: number,string (format: double, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Evidence
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
milestoneId: integer,string (format: int64, nullable: false)
indicatorId: integer,string (format: int64, nullable: false)
activityId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
version: integer,string (format: int32, nullable: false)
type: EvidenceType (format: -, nullable: false)
documentUrl: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: ExecuteContractRequest
Required: [ 'contractId', 'externalSignatureId', 'signedDocumentUrl' ]
contractId*: integer,string (format: int64, nullable: false)
externalSignatureId*: string (format: -, nullable: false)
signedDocumentUrl*: string (format: -, nullable: false)
Model: Indicator
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
unitOfMeasure: any (format: -, nullable: false)
baselineAmount: number,string (format: double, nullable: false)
targetAmount: number,string (format: double, nullable: false)
actualAmount: number,string (format: double, nullable: false)
Model: Instrument
Required: []
id: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
code: null,string (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
Model: Invite
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
invitedOrganisationId: integer,string (format: int64, nullable: false)
awardId: integer,string (format: int64, nullable: false)
contractId: integer,string (format: int64, nullable: false)
recipient: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
Model: InviteRequest
Required: [
'organisationId',
'invitedOrganisationId',
'awardId',
'contractId',
'recipient',
'message',
'platform'
]
organisationId*: integer,string (format: int64, nullable: false)
invitedOrganisationId*: integer,string (format: int64, nullable: false)
awardId*: integer,string (format: int64, nullable: false)
contractId*: integer,string (format: int64, nullable: false)
recipient*: string (format: uuid, nullable: false)
message*: string (format: -, nullable: false)
platform*: NotificationPlatform (format: -, nullable: false)
Model: Milestone
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
dueAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
priority: Priority (format: -, nullable: false)
Model: Organisation
Required: []
id: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
registrationNo: null,string (format: -, nullable: false)
name: null,string (format: -, nullable: false)
email: null,string (format: -, nullable: false)
type: OrganisationType (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
Model: Portfolio
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
ownedBy: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
Model: ProblemDetails
Required: []
type: null,string (format: -, nullable: false)
title: null,string (format: -, nullable: false)
status: null,integer,string (format: int32, nullable: false)
detail: null,string (format: -, nullable: false)
instance: null,string (format: -, nullable: false)
Model: Programme
Required: []
id: integer,string (format: int64, nullable: false)
portfolioId: integer,string (format: int64, nullable: false)
ownedBy: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Project
Required: []
id: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
startedAt: null,string (format: date-time, nullable: false)
endedAt: null,string (format: date-time, nullable: false)
sector: IndustrySector (format: -, nullable: false)
projectType: ProjectType (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Risk
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
likelihood: RiskLikelihood (format: -, nullable: false)
impact: RiskImpact (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
Model: UpdateContractRequest
Required: [ 'contractId', 'status' ]
contractId*: integer,string (format: int64, nullable: false)
status*: ContractStatus (format: -, nullable: false)
Model: UpdateDisbursementRequest
Required: [ 'disbursementId', 'milestoneId', 'amount' ]
disbursementId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: UpdateEvidenceRequest
Required: [ 'evidenceId', 'version', 'evidenceType', 'status' ]
evidenceId*: integer,string (format: int64, nullable: false)
version*: integer,string (format: int32, nullable: false)
evidenceType*: EvidenceType (format: -, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateMilestoneRequest
Required: [ 'milestoneId', 'dueAt', 'name', 'priority', 'status' ]
milestoneId*: integer,string (format: int64, nullable: false)
dueAt*: string (format: date-time, nullable: false)
name*: string (format: -, nullable: false)
priority*: Priority (format: -, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateOrganisationRequest
Required: [ 'organisationId', 'name', 'registrationNo', 'email', 'type' ]
organisationId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
registrationNo*: string (format: -, nullable: false)
email*: string (format: -, nullable: false)
type*: OrganisationType (format: -, nullable: false)
Model: UpdatePortfolioRequest
Required: [ 'portfolioId', 'name', 'description', 'ownerId' ]
portfolioId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: UpdateProgrammeRequest
Required: [ 'programmeId', 'name', 'ownerId', 'status' ]
programmeId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateProjectRequest
Required: [
'projectId',
'name',
'description',
'startedAt',
'endedAt',
'sector',
'projectType'
]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
startedAt*: null,string (format: date-time, nullable: false)
endedAt*: null,string (format: date-time, nullable: false)
sector*: IndustrySector (format: -, nullable: false)
projectType*: ProjectType (format: -, nullable: false)
Model: UpdateProjectStatusRequest
Required: [ 'projectId', 'status' ]
projectId*: integer,string (format: int64, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateRiskRequest
Required: [ 'riskId', 'likelihood', 'impact', 'name', 'description' ]
riskId*: integer,string (format: int64, nullable: false)
likelihood*: RiskLikelihood (format: -, nullable: false)
impact*: RiskImpact (format: -, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
Model: User
Required: []
id: string (format: uuid, nullable: false)
lastLoginAt: null,string (format: date-time, nullable: false)
email: null,string (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
Model: UserFilterRequest
Required: []
status: any (format: -, nullable: false)
email: null,string (format: -, nullable: false)
sortBy: null,string (format: -, nullable: false)
sortDescending: boolean (format: -, nullable: false)
@@ -0,0 +1,919 @@
=== COMPONENT SCHEMAS ===
Activity, ActivityStatus, ApprovalStatus, Award, Budget, Contract, ContractStatus, CreateActivityRequest, CreateAwardRequest, CreateBudgetRequest, CreateContractRequest, CreateDisbursementRequest, CreateIndicatorRequest, CreateMilestoneRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, CreateProjectRequest, CreateRiskRequest, Dashboard, Disbursement, Evidence, EvidenceType, ExecuteContractRequest, Indicator, IndustrySector, Instrument, Invite, InviteRequest, Milestone, NotificationPlatform, Organisation, OrganisationType, Portfolio, Priority, ProblemDetails, Programme, Project, ProjectType, Risk, RiskImpact, RiskLikelihood, UnitOfMeasure, UpdateContractRequest, UpdateDisbursementRequest, UpdateEvidenceRequest, UpdateMilestoneRequest, UpdateOrganisationRequest, UpdatePortfolioRequest, UpdateProgrammeRequest, UpdateProjectRequest, UpdateProjectStatusRequest, UpdateRiskRequest, User, UserFilterRequest
=== DETAILED ENDPOINTS (EXCLUDING TOOLS) ===
------------------------------------------------------------
GET /v1/api/users/{userId} [Tag: Users]
OperationId: getuser
Description: Gets the metadata of a single user.
Parameters:
- userId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/User"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/users/{page}/{pageSize} [Tag: Users]
OperationId: getusers
Description: Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UserFilterRequest"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/User"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/integration/sync [Tag: Users]
OperationId: syncuser
Description: Trigger integration and data synchronization in the background
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/users/{userId}/status/{status} [Tag: Users]
OperationId: updateuserstatus
Description: Changes the Activity status of the user in the system
Parameters:
- userId (in: path, required: true) schema: {"type":"string","format":"uuid"}
- status (in: path, required: true) schema: {"$ref":"#/components/schemas/ActivityStatus"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects/activities [Tag: Projects]
OperationId: createactivity
Description: Add a new activity under project (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateActivityRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects/programme [Tag: Projects]
OperationId: createprogramme
Description: Create programme
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateProgrammeRequest"}
Responses:
[200] (No content)
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/programme [Tag: Projects]
OperationId: updateprogramme
Description: Update existing programme
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProgrammeRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects [Tag: Projects]
OperationId: createproject
Description: Create a new project (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateProjectRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects [Tag: Projects]
OperationId: getprojects
Description: Get a list of projects
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects [Tag: Projects]
OperationId: updateproject
Description: Update project details
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProjectRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/activities [Tag: Projects]
OperationId: getactivities
Description: Get a list of activities for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Activity"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/activities/{activityId} [Tag: Projects]
OperationId: getactivity
Description: Gets a specific activity by it's id
Parameters:
- activityId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Activity"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/milestones/{milestoneId} [Tag: Projects]
OperationId: getmilestone
Description: Gets a specific milestone
Parameters:
- milestoneId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Milestone"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/milestones/{page}/{pageSize} [Tag: Projects]
OperationId: getmilestones
Description: Get a list of all milestones
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Milestone"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/programme/{programmeId} [Tag: Projects]
OperationId: getprogramme
Description: Get specific programme
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Programme"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{programmeId}/programme [Tag: Projects]
OperationId: getprogrammeprojects
Description: Get a list of all projects under a programme
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/programme/{page}/{pageSize} [Tag: Projects]
OperationId: getprogrammesprojects
Description: Get a list of all programmes
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId} [Tag: Projects]
OperationId: getproject
Description: Get project by ID
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/indicators/project/{projectId} [Tag: Projects]
OperationId: getprojectindicators
Description: Get get a detailed list of project indicators
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Indicator"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/milestones [Tag: Projects]
OperationId: getprojectmilestones
Description: Get a list of milestones for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Milestone"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/risks [Tag: Projects]
OperationId: getprojectrisks
Description: Get a list of risks for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Risk"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/milestone [Tag: Projects]
OperationId: updatemilestone
Description: Update project milestone details
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateMilestoneRequest"}
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/status [Tag: Projects]
OperationId: updateprojectstatus
Description: Update project status (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProjectStatusRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/awards [Tag: Grants]
OperationId: createaward
Description: Register an approved funding award (M01)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateAwardRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/awards [Tag: Grants]
OperationId: getawards
Description: Get a list of awards
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":10}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Award"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/budgets [Tag: Grants]
OperationId: createbudget
Description: Create a new budget line (M05)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateBudgetRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/budgets [Tag: Grants]
OperationId: getbudgets
Description: Get all budgets
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Budget"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts [Tag: Grants]
OperationId: createcontract
Description: Create a contract record from an approved award (M02)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts [Tag: Grants]
OperationId: getcontracts
Description: Get all contracts
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Contract"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/grants/contracts [Tag: Grants]
OperationId: updatecontract
Description: Updates a specific contract
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts/execute [Tag: Grants]
OperationId: executecontract
Description: Execute the contract and lock the contractual version
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/ExecuteContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/awards/{awardId} [Tag: Grants]
OperationId: getaward
Description: Get award by ID
Parameters:
- awardId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Award"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/budgets/{budgetId} [Tag: Grants]
OperationId: getbudget
Description: Get budget by ID
Parameters:
- budgetId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Budget"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts/{contractId} [Tag: Grants]
OperationId: getcontract
Description: Get contract by ID
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Contract"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts/{contractId} [Tag: Grants]
OperationId: uploadcontract
Description: Uploads a contract file securely
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] (No content)
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts/{contractId}/securelink [Tag: Grants]
OperationId: getcontracturl
Description: Gets a secure download URL, has expiry period (default 15min)
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"string"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/governance/organisations [Tag: Governance]
OperationId: createorganisation
Description: Create a new organization profile
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateOrganisationRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations [Tag: Governance]
OperationId: getorganisations
Description: Get a paginated directory of organizations
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Organisation"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/governance/organisations [Tag: Governance]
OperationId: updateorganisation
Description: Update an existing organization profile
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateOrganisationRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/governance/organisations/portfolio [Tag: Governance]
OperationId: createportfolio
Description: Create a new organization portfolio
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreatePortfolioRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/governance/organisations/portfolio [Tag: Governance]
OperationId: updateportfolio
Description: Update an existing organization portfolio
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdatePortfolioRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/dashboard [Tag: Governance]
OperationId: dashboard
Description: Get executive portfolio dashboard metrics and KPIs
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Dashboard"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/instruments/{programmeId} [Tag: Governance]
OperationId: getinstruments
Description: Get all funding instruments
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Instrument"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations/{organisationId} [Tag: Governance]
OperationId: getorganisation
Description: Get organization profile by ID
Parameters:
- organisationId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Organisation"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/portfolios/{portfolioId} [Tag: Governance]
OperationId: getportfolio
Description: Get a specific portfolios
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/portfolios/{portfolioId}/programmes [Tag: Governance]
OperationId: getportfolioprogrammes
Description: Get a programme's portfolios
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations/{organisationId}/portfolio [Tag: Governance]
OperationId: getportfolios
Description: Get all portfolios
Parameters:
- organisationId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/programmes/{portfolioId} [Tag: Governance]
OperationId: getprogrammes
Description: Get all programmes
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/disbursements/{disbursementId}/approve [Tag: Execution]
OperationId: approvedisbursement
Description: Approve disbursement and release payment (M06)
Parameters:
- disbursementId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/disbursements [Tag: Execution]
OperationId: createdisbursement
Description: Create a new disbursement schedule (M06)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateDisbursementRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/disbursements [Tag: Execution]
OperationId: getdisbursements
Description: Get a list of disbursements
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":10}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Disbursement"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/execution/disbursements [Tag: Execution]
OperationId: updatedisbursement
Description: Update the details of a specific disbursement
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateDisbursementRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/risks [Tag: Execution]
OperationId: createrisk
Description: Add risk to risk register (M09)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateRiskRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/risks [Tag: Execution]
OperationId: getrisks
Description: Get a list of all risks
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/execution/risks [Tag: Execution]
OperationId: updaterisk
Description: Update the details of a specific risk
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateRiskRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/disbursements/{disbursementId} [Tag: Execution]
OperationId: getdisbursement
Description: Get a specific disbursement
Parameters:
- disbursementId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Disbursement"}
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/risks/{riskId} [Tag: Execution]
OperationId: getrisk
Description: Get a specific risk
Parameters:
- riskId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Risk"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/evidence/file [Tag: Evidence & M&E]
OperationId: createevidence
Description: Upload supporting evidence file to S3 and register metadata (M14)
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/indicators [Tag: Evidence & M&E]
OperationId: createindicator
Description: Define indicators and baselines/targets (M08)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateIndicatorRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/milestones [Tag: Evidence & M&E]
OperationId: createmilestonerequest
Description: Define contractual milestones (M07)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateMilestoneRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{evidenceId} [Tag: Evidence & M&E]
OperationId: getevidence
Description: Get a specific evidence record and assiciated file URLs
Parameters:
- evidenceId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Evidence"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/files/{page}/{pageSize} [Tag: Evidence & M&E]
OperationId: getevidencefiles
Description: Get a list of all uploaded evidence files metadata
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{evidenceId}/securelink [Tag: Grants]
OperationId: getevidenceurl
Description: Gets a secure download URL of the evidence file, URL expires (default: 15min)
Parameters:
- evidenceId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"string"}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"type":"string"}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/indicators/{indicatorId} [Tag: Evidence & M&E]
OperationId: getindicator
Description: Get a specific indicator.
Parameters:
- indicatorId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Indicator"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/indicators/{page}/{pageSize} [Tag: Evidence & M&E]
OperationId: getindicators
Description: Get a list of indicators
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Indicator"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/milestone/{milestoneId} [Tag: Evidence & M&E]
OperationId: getmilestoneevidence
Description: Get a list of all uploaded evidence metadata for a specific mliestone
Parameters:
- milestoneId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{projectId} [Tag: Evidence & M&E]
OperationId: getprojectevidence
Description: Get a list of all uploaded evidence metadata for a specific project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/indicators/{indicatorId}/actuals/{actualAmount} [Tag: Evidence & M&E]
OperationId: recordindicatoramount
Description: Record actual values for indicators (M08)
Parameters:
- indicatorId (in: path, required: true) schema: {"type":"string"}
- actualAmount (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$","type":"number","format":"double"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/evidence [Tag: Evidence & M&E]
OperationId: updateevidence
Description: Updates a specific evidence record metadata
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateEvidenceRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/applicant/{applicantUserId} [Tag: Communications]
OperationId: getapplicantinvite
Description: Gets an applicant's invite
Parameters:
- applicantUserId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Invite"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/{applicantUserId} [Tag: Communications]
OperationId: getapplicantinvites
Description: Gets an applicant's invite list
Parameters:
- applicantUserId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Invite"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/{page}/{pageSize} [Tag: Communications]
OperationId: getinvites
Description: Get a list of sent invitations
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Invite"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/communications/invite [Tag: Communications]
OperationId: sendinvite
Description: Trigger an invite to an organization or candidate (specifying NotificationPlatform)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/InviteRequest"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Invite"}
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -5,6 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
"dev:https": "HTTPS=true vite --port=3000 --host=0.0.0.0",
"dev:http": "HTTPS=false vite --port=3000 --host=0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist server.js", "clean": "rm -rf dist server.js",
@@ -13,12 +15,14 @@
"dependencies": { "dependencies": {
"@google/genai": "^2.4.0", "@google/genai": "^2.4.0",
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.102.8", "@tanstack/react-query": "^5.102.8",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"axios": "^1.20.0", "axios": "^1.20.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"hashids": "^2.3.0",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
@@ -26,19 +30,16 @@
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"react-hook-form": "^7.87.0", "react-hook-form": "^7.87.0",
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14",
"vite": "^6.2.3", "vite": "^6.2.3",
"zod": "^4.5.4" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-basic-ssl": "^2.3.0", "autoprefixer": "^10.4.21",
"autoprefixer": "^10.5.6",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.8.2", "typescript": "~5.8.2"
"vite": "^6.2.3"
} }
} }
@@ -0,0 +1,16 @@
{
"ApiSettings": {
"BaseUrl": ""
},
"SecuritySettings": {
"Authority": "",
"Audience": "",
"ClientId": "",
"ClientSecret": "",
"Scope": ""
},
"HasherSettings": {
"Salt": "",
"MinHashLength": ""
}
}
@@ -0,0 +1,4 @@
// 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__ || {};
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<!-- Ambient Radial Glow -->
<radialGradient id="pfm-ambient-glow" 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>
<!-- Top facet: Governance & Portfolios (Indigo / Radiant Violet) -->
<linearGradient id="pfm-grad-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>
<!-- Left facet: Capital & Disbursement Vault (Electric Sapphire) -->
<linearGradient id="pfm-grad-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>
<!-- Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) -->
<linearGradient id="pfm-grad-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>
<!-- Center Radiant Diamond -->
<linearGradient id="pfm-grad-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>
<!-- Soft outer glow filter -->
<filter id="pfm-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Deep Contrast Foundation Disk for Dark & Light browser tabs -->
<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-glow)" />
<!-- Outer Hexagonal Structural Shield Outline -->
<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"
/>
<!-- Facet 1: Top Canopy (Governance / Oversight Architecture) -->
<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-grad-top)"
/>
<!-- Facet 2: Left Vault (Fund Management & Capital Allocation) -->
<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-grad-left)"
/>
<!-- Facet 3: Right Wing (Milestone Performance & Impact Assurance) -->
<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-grad-right)"
/>
<!-- Center Geometric Hub (The PFM Nexus Plate) -->
<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"
/>
<!-- Kinetic Stepped Prisms -->
<path
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
fill="url(#pfm-grad-top)"
opacity="0.9"
/>
<path
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
fill="url(#pfm-grad-right)"
opacity="0.9"
/>
<!-- Center Radiant Milestone Diamond -->
<polygon
points="32,27 36.5,32 32,37 27.5,32"
fill="url(#pfm-grad-core)"
filter="url(#pfm-glow)"
/>
<!-- Center Pure White Optical Star Point -->
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,469 @@
=== SCHEMA: Activity ===
Required: []
- id: integer,string (format: int64)
- projectId: null,integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- category: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
- amount: number,string (format: double)
- title: null,string (format: none)
- description: null,string (format: none)
- performedAt: null,string (format: date-time)
=== SCHEMA: ActivityStatus ===
Other: { type: 'integer' }
=== SCHEMA: ApprovalStatus ===
Other: { type: 'integer' }
=== SCHEMA: Award ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- projectId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- status: #/components/schemas/ApprovalStatus (format: none)
- amount: number,string (format: double)
=== SCHEMA: Budget ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- name: null,string (format: none)
- amount: number,string (format: double)
=== SCHEMA: Contract ===
Required: []
- id: integer,string (format: int64)
- awardId: integer,string (format: int64)
- externalSignatureId: null,string (format: none)
- signedDocumentUrl: null,string (format: none)
- effectiveAt: null,string (format: date-time)
- expiresAt: null,string (format: date-time)
- totalValue: number,string (format: double)
- status: #/components/schemas/ContractStatus (format: none)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
=== SCHEMA: ContractStatus ===
Other: { type: 'integer' }
=== SCHEMA: CreateActivityRequest ===
Required: [
'projectId',
'milestoneId',
'category',
'amount',
'title',
'description',
'performedAt'
]
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- category: string (format: none)
- amount: number,string (format: double)
- title: string (format: none)
- description: string (format: none)
- performedAt: null,string (format: date-time)
=== SCHEMA: CreateAwardRequest ===
Required: [ 'organisationId', 'programmeId', 'amount' ]
- organisationId: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: CreateBudgetRequest ===
Required: [ 'projectId', 'name', 'amount' ]
- projectId: integer,string (format: int64)
- name: string (format: none)
- amount: number,string (format: double)
=== SCHEMA: CreateContractRequest ===
Required: [ 'awardId', 'totalValue', 'effectiveAt', 'expiresAt' ]
- awardId: integer,string (format: int64)
- totalValue: number,string (format: double)
- effectiveAt: string (format: date-time)
- expiresAt: string (format: date-time)
=== SCHEMA: CreateDisbursementRequest ===
Required: [ 'projectId', 'milestoneId', 'amount' ]
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: CreateIndicatorRequest ===
Required: [
'projectId',
'name',
'unitOfMeasure',
'baselineAmount',
'targetAmount'
]
- projectId: integer,string (format: int64)
- name: string (format: none)
- unitOfMeasure: #/components/schemas/UnitOfMeasure (format: none)
- baselineAmount: number,string (format: double)
- targetAmount: number,string (format: double)
=== SCHEMA: CreateMilestoneRequest ===
Required: [ 'projectId', 'dueAt', 'name', 'priority' ]
- projectId: integer,string (format: int64)
- dueAt: string (format: date-time)
- name: string (format: none)
- priority: #/components/schemas/Priority (format: none)
=== SCHEMA: CreateOrganisationRequest ===
Required: [ 'name', 'registrationNo', 'email' ]
- name: string (format: none)
- registrationNo: string (format: none)
- email: string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
=== SCHEMA: CreatePortfolioRequest ===
Required: [ 'name', 'description', 'organisationId', 'ownerId' ]
- name: string (format: none)
- description: string (format: none)
- organisationId: integer,string (format: int64)
- ownerId: string (format: uuid)
=== SCHEMA: CreateProgrammeRequest ===
Required: [ 'portfolioId', 'name', 'ownerId' ]
- portfolioId: integer,string (format: int64)
- name: string (format: none)
- ownerId: string (format: uuid)
=== SCHEMA: CreateProjectRequest ===
Required: [ 'programmeId', 'name', 'description', 'sector', 'projectType' ]
- programmeId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
=== SCHEMA: CreateRiskRequest ===
Required: [ 'projectId', 'likelihood', 'impact', 'name', 'description' ]
- projectId: integer,string (format: int64)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: string (format: none)
- description: string (format: none)
=== SCHEMA: Dashboard ===
Required: [
'portfolioValue',
'activeAwards',
'disbursed',
'onTrackPercentage',
'atRiskCount',
'beneficiariesReached'
]
- portfolioValue: number,string (format: double)
- activeAwards: integer,string (format: int32)
- disbursed: number,string (format: double)
- onTrackPercentage: number,string (format: double)
- atRiskCount: integer,string (format: int32)
- beneficiariesReached: integer,string (format: int32)
=== SCHEMA: Disbursement ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- amount: number,string (format: double)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: Evidence ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- milestoneId: integer,string (format: int64)
- indicatorId: integer,string (format: int64)
- activityId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- version: integer,string (format: int32)
- type: #/components/schemas/EvidenceType (format: none)
- documentUrl: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: EvidenceType ===
Other: { type: 'integer' }
=== SCHEMA: ExecuteContractRequest ===
Required: [ 'contractId', 'externalSignatureId', 'signedDocumentUrl' ]
- contractId: integer,string (format: int64)
- externalSignatureId: string (format: none)
- signedDocumentUrl: string (format: none)
=== SCHEMA: Indicator ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- name: null,string (format: none)
- unitOfMeasure: any (format: none)
- baselineAmount: number,string (format: double)
- targetAmount: number,string (format: double)
- actualAmount: number,string (format: double)
=== SCHEMA: IndustrySector ===
Other: { type: 'integer' }
=== SCHEMA: Instrument ===
Required: []
- id: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- code: null,string (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
=== SCHEMA: Invite ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- invitedOrganisationId: integer,string (format: int64)
- awardId: integer,string (format: int64)
- contractId: integer,string (format: int64)
- recipient: string (format: uuid)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
=== SCHEMA: InviteRequest ===
Required: [
'organisationId',
'invitedOrganisationId',
'awardId',
'contractId',
'recipient',
'message',
'platform'
]
- organisationId: integer,string (format: int64)
- invitedOrganisationId: integer,string (format: int64)
- awardId: integer,string (format: int64)
- contractId: integer,string (format: int64)
- recipient: string (format: uuid)
- message: string (format: none)
- platform: #/components/schemas/NotificationPlatform (format: none)
=== SCHEMA: Milestone ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- dueAt: string (format: date-time)
- name: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
- priority: #/components/schemas/Priority (format: none)
=== SCHEMA: NotificationPlatform ===
Other: { type: 'integer' }
=== SCHEMA: Organisation ===
Required: []
- id: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- registrationNo: null,string (format: none)
- name: null,string (format: none)
- email: null,string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
=== SCHEMA: OrganisationType ===
Other: { type: 'integer', default: 5 }
=== SCHEMA: Portfolio ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- ownedBy: string (format: uuid)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- name: null,string (format: none)
- description: null,string (format: none)
=== SCHEMA: Priority ===
Other: { type: 'integer' }
=== SCHEMA: ProblemDetails ===
Required: []
- type: null,string (format: none)
- title: null,string (format: none)
- status: null,integer,string (format: int32)
- detail: null,string (format: none)
- instance: null,string (format: none)
=== SCHEMA: Programme ===
Required: []
- id: integer,string (format: int64)
- portfolioId: integer,string (format: int64)
- ownedBy: string (format: uuid)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- name: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: Project ===
Required: []
- id: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- startedAt: null,string (format: date-time)
- endedAt: null,string (format: date-time)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: ProjectType ===
Other: { type: 'integer' }
=== SCHEMA: Risk ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
=== SCHEMA: RiskImpact ===
Other: { type: 'integer' }
=== SCHEMA: RiskLikelihood ===
Other: { type: 'integer' }
=== SCHEMA: UnitOfMeasure ===
Other: { type: 'integer' }
=== SCHEMA: UpdateContractRequest ===
Required: [ 'contractId', 'status' ]
- contractId: integer,string (format: int64)
- status: #/components/schemas/ContractStatus (format: none)
=== SCHEMA: UpdateDisbursementRequest ===
Required: [ 'disbursementId', 'milestoneId', 'amount' ]
- disbursementId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: UpdateEvidenceRequest ===
Required: [ 'evidenceId', 'version', 'evidenceType', 'status' ]
- evidenceId: integer,string (format: int64)
- version: integer,string (format: int32)
- evidenceType: #/components/schemas/EvidenceType (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateMilestoneRequest ===
Required: [ 'milestoneId', 'dueAt', 'name', 'priority', 'status' ]
- milestoneId: integer,string (format: int64)
- dueAt: string (format: date-time)
- name: string (format: none)
- priority: #/components/schemas/Priority (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateOrganisationRequest ===
Required: [ 'organisationId', 'name', 'registrationNo', 'email', 'type' ]
- organisationId: integer,string (format: int64)
- name: string (format: none)
- registrationNo: string (format: none)
- email: string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
=== SCHEMA: UpdatePortfolioRequest ===
Required: [ 'portfolioId', 'name', 'description', 'ownerId' ]
- portfolioId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- ownerId: string (format: uuid)
=== SCHEMA: UpdateProgrammeRequest ===
Required: [ 'programmeId', 'name', 'ownerId', 'status' ]
- programmeId: integer,string (format: int64)
- name: string (format: none)
- ownerId: string (format: uuid)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateProjectRequest ===
Required: [
'projectId',
'name',
'description',
'startedAt',
'endedAt',
'sector',
'projectType'
]
- projectId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- startedAt: null,string (format: date-time)
- endedAt: null,string (format: date-time)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
=== SCHEMA: UpdateProjectStatusRequest ===
Required: [ 'projectId', 'status' ]
- projectId: integer,string (format: int64)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateRiskRequest ===
Required: [ 'riskId', 'likelihood', 'impact', 'name', 'description' ]
- riskId: integer,string (format: int64)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: string (format: none)
- description: string (format: none)
=== SCHEMA: User ===
Required: []
- id: string (format: uuid)
- lastLoginAt: null,string (format: date-time)
- email: null,string (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
=== SCHEMA: UserFilterRequest ===
Required: []
- status: any (format: none)
- email: null,string (format: none)
- sortBy: null,string (format: none)
- sortDescending: boolean (format: none)
+20 -8
View File
@@ -1,17 +1,27 @@
import React from 'react'; import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { AuthProvider, ProtectedRoute } from './auth/AuthProvider'; import { AuthProvider, ProtectedRoute } from './auth/AuthProvider';
import { ToastProvider } from './components/ui/Toast'; import { ToastProvider } from './components/ui/Toast';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { LandingPage } from './pages/LandingPage';
import { ExecutiveDashboard } from './pages/ExecutiveDashboard'; const LandingPage = lazy(() => import('./pages/LandingPage').then(m => ({ default: m.LandingPage })));
import { GovernancePage } from './pages/GovernancePage'; const ExecutiveDashboard = lazy(() => import('./pages/ExecutiveDashboard').then(m => ({ default: m.ExecutiveDashboard })));
import { ProjectsPage } from './pages/ProjectsPage'; const GovernancePage = lazy(() => import('./pages/GovernancePage').then(m => ({ default: m.GovernancePage })));
import { GrantsPage } from './pages/GrantsPage'; const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ default: m.ProjectsPage })));
import { MonitoringPage } from './pages/MonitoringPage'; const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
import { SettingsPage } from './pages/SettingsPage'; const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
const RouteLoading = () => (
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
<div className="flex flex-col items-center gap-3">
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-xs font-medium text-slate-400">Loading module...</span>
</div>
</div>
);
export default function App() { export default function App() {
return ( return (
@@ -19,6 +29,7 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
<Suspense fallback={<RouteLoading />}>
<Routes> <Routes>
{/* Public OIDC Login / Landing Page */} {/* Public OIDC Login / Landing Page */}
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
@@ -43,6 +54,7 @@ export default function App() {
{/* Fallback Redirect */} {/* Fallback Redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</Suspense>
</ToastProvider> </ToastProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
@@ -1,8 +1,27 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts'; import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getSecuritySettings, loadRuntimeConfig } from '../config'; import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config';
import { parseJwtClaims } from './jwt'; import { parseJwtClaims } from './jwt';
import { BrandLogo } from '../components/ui/BrandLogo';
import {
syncUserAccount,
isUserSyncedInBrowser,
isActivationPending,
getActivationPendingReason,
clearUserSyncInBrowser,
UserSyncResult,
} from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import {
onSessionExpired,
triggerSessionExpired,
clearSessionAndRedirectToLanding,
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
export interface AuthUserProfile { export interface AuthUserProfile {
id: string; id: string;
@@ -11,6 +30,8 @@ export interface AuthUserProfile {
email: string; email: string;
role: string; role: string;
organisationName?: string; organisationName?: string;
tenant?: string;
tenant_id?: number;
avatarUrl?: string; avatarUrl?: string;
} }
@@ -20,11 +41,19 @@ interface AuthContextType {
user: AuthUserProfile | null; user: AuthUserProfile | null;
sub: string; sub: string;
claims: UserClaims; claims: UserClaims;
tenantId: number | null;
hashedTenantId: string | null;
accessToken: string | null; accessToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
isSynced: boolean;
isPendingActivation: boolean;
activationError: string | null;
isSessionExpired: boolean;
triggerSessionExpired: (reason?: string) => void;
login: () => Promise<void>; login: () => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
syncUserSession: (force?: boolean) => Promise<UserSyncResult>;
authError: string | null; authError: string | null;
} }
@@ -35,6 +64,75 @@ export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null {
if (claims) {
const raw =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
if (token) {
try {
const tokenClaims = parseJwtClaims(token);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
} catch {}
}
if (typeof window !== 'undefined') {
try {
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedClaimsRaw) {
const parsedClaims = JSON.parse(storedClaimsRaw);
const raw = parsedClaims.tenant_id ?? parsedClaims.tenantId ?? parsedClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (storedToken) {
const tokenClaims = parseJwtClaims(storedToken);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
if (storedUser) {
const parsedUser = JSON.parse(storedUser);
const raw = parsedUser.tenant_id ?? parsedUser.tenantId;
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
} catch {}
}
return null;
}
export function getCurrentUserTenantId(): number | null {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
}
export function getCurrentUserHashedTenantId(): string | null {
const tid = getCurrentUserTenantId();
if (tid === null || tid === undefined) return null;
return encodeHashIdSync(tid);
}
export function getCurrentUserSub(): string { export function getCurrentUserSub(): string {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || ''; return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
@@ -58,7 +156,16 @@ export function getCurrentUserClaims(): UserClaims {
let userManagerInstance: UserManager | null = null; let userManagerInstance: UserManager | null = null;
function getUserManager(): UserManager { 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') { if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings(); const sec = getSecuritySettings();
const origin = window.location.origin; const origin = window.location.origin;
@@ -70,7 +177,7 @@ function getUserManager(): UserManager {
redirect_uri: `${origin}/dashboard`, redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`, post_logout_redirect_uri: `${origin}/`,
response_type: 'code', response_type: 'code',
scope: 'openid profile roles email address pfm-api-dev', scope: sec.Scope || 'openid profile roles email address pfm-api-dev',
extraQueryParams: { extraQueryParams: {
audience: sec.Audience, audience: sec.Audience,
}, },
@@ -85,11 +192,67 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [user, setUser] = useState<AuthUserProfile | null>(null); const [user, setUser] = useState<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>(''); const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({}); const [claims, setClaims] = useState<UserClaims>({});
const [tenantId, setTenantId] = useState<number | null>(() => {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
});
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
const [isPendingActivation, setIsPendingActivation] = useState<boolean>(() => isActivationPending());
const [activationError, setActivationError] = useState<string | null>(() => getActivationPendingReason());
const [authError, setAuthError] = useState<string | null>(null); const [authError, setAuthError] = useState<string | null>(null);
const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
// Initialize auth state and check for OIDC callback const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null;
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
// If account activation is pending, suppress session expiration modal
if (isPendingActivation || isActivationPending()) {
return;
}
// Only display modal if user currently has an active token or authenticated state
const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null;
if (token || accessToken) {
setSessionExpiredReason(reason || 'Session expired or token invalidated (HTTP 401)');
setIsSessionExpired(true);
}
});
return () => {
unsubscribe();
};
}, [accessToken, isPendingActivation]);
// When account activation is pending, ensure secondary dialogs are closed
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
setSessionExpiredReason(null);
cancelSessionExpiration();
dismissSubordinateModals();
}
}, [isPendingActivation]);
// When session expiration modal is displayed, dismiss background modals
useEffect(() => {
if (isSessionExpired) {
dismissSubordinateModals();
}
}, [isSessionExpired]);
const handleSessionExpiredDismiss = () => {
setIsSessionExpired(false);
setSessionExpiredReason(null);
clearSessionAndRedirectToLanding();
};
// Initialize auth state, check for OIDC callback, and perform browser-cached user sync
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
try { try {
@@ -120,6 +283,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub); localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
const profile: AuthUserProfile = { const profile: AuthUserProfile = {
id: userSub || 'user-1', id: userSub || 'user-1',
sub: userSub, sub: userSub,
@@ -127,6 +294,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
email: oidcUser.profile.email || (mergedClaims.email as string) || '', email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'), role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
tenant: tenantClaim,
tenant_id: parsedTenantId ?? undefined,
}; };
setUser(profile); setUser(profile);
@@ -134,6 +303,35 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL // Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
// 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);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
window.location.replace('/');
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -152,7 +350,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (storedClaimsRaw) { if (storedClaimsRaw) {
try { try {
parsedClaims = JSON.parse(storedClaimsRaw); parsedClaims = JSON.parse(storedClaimsRaw);
} catch { } } catch {}
} else { } else {
parsedClaims = parseJwtClaims(storedToken); parsedClaims = parseJwtClaims(storedToken);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
@@ -167,10 +365,61 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub); setSub(storedSub);
} }
const storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) { if (storedUser) {
try { try {
setUser(JSON.parse(storedUser)); const parsedUser = JSON.parse(storedUser);
} catch { } if (!parsedUser.tenant && parsedClaims.tenant) {
parsedUser.tenant = parsedClaims.tenant;
}
if (parsedUser.tenant_id === undefined && storedTenantId !== null) {
parsedUser.tenant_id = storedTenantId;
}
setUser(parsedUser);
resolvedUser = parsedUser;
} catch {}
}
// Check if session is already marked pending activation or needs sync check
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
try {
// Verify sync with browser cache priority (hotspot prevention)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
// HTTP 401: Normal process of routing user to landing page
await logout();
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
@@ -184,17 +433,48 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
initAuth(); initAuth();
}, []); }, []);
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try {
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.success) {
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';
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(msg);
return { success: false, isActivationPending: true, errorMessage: msg };
}
};
const login = async () => { const login = async () => {
try { try {
setAuthError(null); setAuthError(null);
await loadRuntimeConfig();
resetUserManager();
const mgr = getUserManager(); const mgr = getUserManager();
await mgr.signinRedirect(); await mgr.signinRedirect();
} catch (err: unknown) { } catch (err: unknown) {
console.warn('OIDC Redirect encountered:', err); console.warn('OIDC Redirect encountered:', err);
// In sandboxed preview iframe, navigation to third-party STS domain can be blocked by CSP/frame-ancestors.
if (typeof window !== 'undefined' && window.self !== window.top) { if (typeof window !== 'undefined' && window.self !== window.top) {
const errorMsg = const errorMsg =
'Identity Provider redirect was restricted inside this preview frame. For testing with Duende STS (https://sts.security.khongisa.co.za), open the application in a new tab or Docker container.'; 'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.';
setAuthError(errorMsg); setAuthError(errorMsg);
} else { } else {
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error'); setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
@@ -208,10 +488,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.removeItem(LOCAL_STORAGE_USER_KEY); localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY); localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
clearUserSyncInBrowser();
setUser(null); setUser(null);
setSub(''); setSub('');
setClaims({}); setClaims({});
setTenantId(null);
setAccessToken(null); setAccessToken(null);
setIsSynced(false);
setIsPendingActivation(false);
setActivationError(null);
try { try {
const mgr = getUserManager(); const mgr = getUserManager();
@@ -230,15 +515,29 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user, user,
sub, sub,
claims, claims,
tenantId,
hashedTenantId,
accessToken, accessToken,
isAuthenticated: !!accessToken, isAuthenticated: !!accessToken,
isLoading, isLoading,
isSynced,
isPendingActivation,
activationError,
isSessionExpired,
triggerSessionExpired,
login, login,
logout, logout,
syncUserSession,
authError, authError,
}} }}
> >
{children} {children}
<SessionExpiredModal
isOpen={isSessionExpired && !isPendingActivation}
reason={sessionExpiredReason}
onDismiss={handleSessionExpiredDismiss}
countdownSeconds={8}
/>
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
@@ -253,15 +552,28 @@ export const useAuth = () => {
// Route Guard // Route Guard
export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => { export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth(); const {
isAuthenticated,
isLoading,
isPendingActivation,
user,
claims,
sub,
logout,
syncUserSession,
activationError,
} = useAuth();
const location = useLocation(); const location = useLocation();
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-screen w-full items-center justify-center bg-[#0d0d0d]"> <div className="flex h-screen w-full items-center justify-center bg-[#090d16]">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div> <div className="relative flex items-center justify-center">
<p className="text-xs font-medium text-gray-400">Verifying PFM Console Session...</p> <BrandLogo size={52} showGlow animateOnHover={false} />
<div className="absolute -inset-2.5 rounded-full border-2 border-blue-500/20 border-t-blue-400 animate-spin" />
</div>
<p className="text-xs font-medium text-slate-400 tracking-wide">Verifying PFM Console Session...</p>
</div> </div>
</div> </div>
); );
@@ -271,5 +583,20 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
return <Navigate to="/" state={{ from: location }} replace />; return <Navigate to="/" state={{ from: location }} replace />;
} }
// If user is authenticated but account has not yet been enabled by an administrator:
// Render the non-dismissable activation modal, blocking all downstream layout and queries.
if (isPendingActivation) {
return (
<AccountActivationModal
user={user}
claims={claims}
sub={sub}
onSync={syncUserSession}
onLogout={logout}
initialError={activationError}
/>
);
}
return <>{children}</>; return <>{children}</>;
}; };
@@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShieldAlert,
RefreshCw,
LogOut,
UserCheck,
CheckCircle2,
AlertTriangle,
ArrowLeft,
Mail,
Building2,
Fingerprint,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface AccountActivationModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>;
onLogout: () => Promise<void>;
initialError?: string | null;
}
export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
user,
claims,
sub,
onSync,
onLogout,
initialError,
}) => {
const navigate = useNavigate();
const [isSyncing, setIsSyncing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
// Clear any existing session prompt and close background dialogs
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
'Registered User';
const displayEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
'';
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Standard Enterprise';
const role = user?.role || (claims.role as string) || 'User';
const handleSyncClick = async () => {
if (isSyncing) return;
setIsSyncing(true);
setErrorMessage(null);
try {
const result = await onSync(true);
if (result.isUnauthorized) {
// HTTP 401: Routine process of routing user to landing page
await onLogout();
navigate('/', { replace: true });
return;
}
if (!result.success) {
setSyncAttemptCount((prev) => prev + 1);
setErrorMessage(
result.errorMessage ||
'Account is not yet enabled. The administrator has not activated your account in the system directory.'
);
}
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
} catch (err: any) {
if (err?.response?.status === 401) {
await onLogout();
navigate('/', { replace: true });
return;
}
setErrorMessage(
err?.message ||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
);
} finally {
setIsSyncing(false);
}
};
const handleSignOut = async () => {
await onLogout();
navigate('/', { replace: true });
};
const handleNavigateLanding = () => {
navigate('/');
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="activation-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none"
>
{/* 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"
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" />
{/* Modal Header */}
<div className="p-6 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>
<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 • Awaiting Enablement
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
Account Pending Administrator 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>
</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>
<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>
</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>
</div>
</div>
</div>
{/* Sync Attempt Feedback / Error Notice */}
{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">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>
</div>
)}
</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">
<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."
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span>
</button>
<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"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
<button
onClick={handleSyncClick}
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"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,186 @@
import React, { useEffect, useState, useRef } from 'react';
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
interface SessionExpiredModalProps {
isOpen: boolean;
onDismiss?: () => void;
reason?: string | null;
/** Total countdown time in seconds (defaults to 8 seconds) */
countdownSeconds?: number;
}
export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
isOpen,
onDismiss,
reason,
countdownSeconds = 8,
}) => {
const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000);
const [isPaused, setIsPaused] = useState(false);
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
// Close background modals when session expires
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
// Reset state if session expiration is cancelled
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
};
window.addEventListener('pfm:session-expired-cancelled', handleCancelled);
return () => {
window.removeEventListener('pfm:session-expired-cancelled', handleCancelled);
};
}, [countdownSeconds]);
// Reset or start countdown when modal opens
useEffect(() => {
if (!isOpen) {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
return;
}
const intervalMs = 50;
const interval = setInterval(() => {
setTimeLeftMs((prev) => {
if (isPaused) return prev;
const next = prev - intervalMs;
if (next <= 0) {
clearInterval(interval);
handleAction();
return 0;
}
return next;
});
}, intervalMs);
return () => clearInterval(interval);
}, [isOpen, isPaused, countdownSeconds]);
const handleAction = () => {
if (isClearingRef.current) return;
isClearingRef.current = true;
if (onDismiss) {
onDismiss();
} else {
clearSessionAndRedirectToLanding();
}
};
if (!isOpen) return null;
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
return (
<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"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
role="dialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<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"
>
{/* 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" />
{/* 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>
<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>
</div>
</div>
<button
id="session-expired-close-btn"
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"
>
<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">
<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.
</p>
{reason && (
<div className="flex items-start gap-2 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">
<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>
</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>
)}
</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">
<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"
>
<LogOut className="w-4 h-4" />
<span>Return to Landing Page Now</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
</button>
</div>
</div>
</div>
);
};
@@ -12,48 +12,58 @@ import {
X, X,
ChevronRight, ChevronRight,
ShieldCheck, ShieldCheck,
Layers,
Database, Database,
Home, Home,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider'; import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
const navItems = [ const navItems = [
{ {
name: 'Executive Dashboard', name: 'Executive Dashboard',
path: '/dashboard', path: '/dashboard',
icon: LayoutDashboard, icon: LayoutDashboard,
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
}, },
{ {
name: 'Governance & Structure', name: 'Governance & Structure',
path: '/governance', path: '/governance',
icon: Building2, icon: Building2,
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
}, },
{ {
name: 'Projects & Milestones', name: 'Projects & Milestones',
path: '/projects', path: '/projects',
icon: FolderKanban, icon: FolderKanban,
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
}, },
{ {
name: 'Grant & Financials', name: 'Grant & Financials',
path: '/grants', path: '/grants',
icon: Coins, icon: Coins,
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
}, },
{ {
name: 'M&E, Evidence & Risks', name: 'M&E, Evidence & Risks',
path: '/monitoring', path: '/monitoring',
icon: ShieldAlert, icon: ShieldAlert,
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
}, },
{ {
name: 'Settings & Communications', name: 'Settings & Communications',
path: '/settings', path: '/settings',
icon: Settings, icon: Settings,
help: 'User account provisioning, statutory sync, tenant environment parameters, and communication invite templates.',
}, },
]; ];
export const AppLayout: React.FC = () => { export const AppLayout: React.FC = () => {
const { user, claims, sub, logout } = useAuth(); const { user, claims, sub, tenantId, hashedTenantId, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -62,6 +72,22 @@ export const AppLayout: React.FC = () => {
await logout(); await logout();
}; };
// Pre-populate user directory cache so all components and modals resolve user emails instantly
useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Ensure current logged-in user is registered in the email cache
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
if (sub && effectiveEmail) {
registerUserEmail(sub, effectiveEmail);
}
if (user?.id && effectiveEmail) {
registerUserEmail(user.id, effectiveEmail);
}
// Extract name and email directly from validated token claims // Extract name and email directly from validated token claims
const tokenUserName = const tokenUserName =
user?.name || user?.name ||
@@ -75,16 +101,26 @@ export const AppLayout: React.FC = () => {
(claims.email as string) || (claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims.upn as string) || (claims.upn as string) ||
(sub ? `sub:${sub}` : ''); (sub ? getUserEmail(sub) : '');
// Extract tenant name directly from the user claim named 'tenant'
const tenantName =
(claims?.tenant as string) ||
user?.tenant ||
(claims?.['tenant'] as string) ||
user?.organisationName ||
(claims?.organisation as string) ||
'Department of Science, Innovation & Technology (DSI)';
return ( return (
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white"> <div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={`${sidebarOpen ? 'w-64' : 'w-20' className={`${
sidebarOpen ? 'w-64' : 'w-20'
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`} } shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
> >
{/* Brand Header — Clickable link navigating to auth-aware Home page */} {/* Brand Header */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90"> <div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
<Link <Link
to="/" to="/"
@@ -92,12 +128,10 @@ export const AppLayout: React.FC = () => {
title="Go to Home page" title="Go to Home page"
className="flex items-center gap-3 overflow-hidden group cursor-pointer" className="flex items-center gap-3 overflow-hidden group cursor-pointer"
> >
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20 group-hover:scale-105 transition"> <BrandLogo size={36} showGlow animateOnHover />
<Layers className="w-5 h-5 text-white" />
</div>
{sidebarOpen && ( {sidebarOpen && (
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="font-semibold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition"> <span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
PFM Console PFM Console
</span> </span>
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span> <span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
@@ -122,7 +156,8 @@ export const AppLayout: React.FC = () => {
key={item.path} key={item.path}
to={item.path} to={item.path}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${isActive `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' ? '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' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}` }`
@@ -135,7 +170,7 @@ export const AppLayout: React.FC = () => {
})} })}
</nav> </nav>
{/* User Card with Token-derived info & Identity Server Logout */} {/* User Card */}
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90"> <div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400"> <div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
@@ -147,7 +182,7 @@ export const AppLayout: React.FC = () => {
{tokenUserName} {tokenUserName}
</p> </p>
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}> <p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
{tokenUserEmail || 'STS Token User'} {tokenUserEmail || 'Authenticated User'}
</p> </p>
</div> </div>
)} )}
@@ -155,7 +190,7 @@ export const AppLayout: React.FC = () => {
onClick={handleLogout} onClick={handleLogout}
id="sidebar-logout-btn" id="sidebar-logout-btn"
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer" className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
title="Sign Out via Identity Server" title="Sign Out of Session"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
</button> </button>
@@ -166,9 +201,9 @@ export const AppLayout: React.FC = () => {
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Top Header */} {/* Top Header */}
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-6 md:px-8 flex items-center justify-between shrink-0 z-20"> <header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Clickable Home Link */} {/* Home Navigation */}
<Link <Link
to="/" to="/"
id="header-home-link" id="header-home-link"
@@ -183,21 +218,48 @@ export const AppLayout: React.FC = () => {
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300"> <div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
<span className="text-slate-400">Tenant:</span> <span className="text-slate-400">Tenant:</span>
<span className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs"> <span
<Database className="w-3.5 h-3.5 text-blue-400" /> className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate"
Department of Science, Innovation & Technology (DSI) title={`${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
>
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{tenantName}</span>
{hashedTenantId && (
<span
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
title={`Tenant Partition HashID: ${hashedTenantId}`}
>
{hashedTenantId}
</span> </span>
)}
</span>
<HelpTooltip
title="Tenant Context"
content={`Identifies the multi-tenant organization partition and database boundary within the national PFM environment.${
hashedTenantId ? ` Tenant Partition (HashID): ${hashedTenantId}` : ''
}`}
size="sm"
placement="bottom"
/>
</div> </div>
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" /> <ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs"> <span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" /> <ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session Authenticated Session
</span> </span>
<HelpTooltip
title="Security Session Status"
content="Current session is verified against OIDC Identity Server with active cryptographic token and claims."
size="sm"
placement="bottom"
/>
</div>
</div> </div>
{/* User Name & Email derived from token, plus Identity Server signout */} {/* User Profile and Controls */}
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3">
<div className="text-right hidden sm:block"> <div className="text-right hidden sm:block">
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}> <p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
@@ -225,8 +287,7 @@ export const AppLayout: React.FC = () => {
> >
<div className="p-2.5 border-b border-slate-800"> <div className="p-2.5 border-b border-slate-800">
<p className="text-xs font-semibold text-white">{tokenUserName}</p> <p className="text-xs font-semibold text-white">{tokenUserName}</p>
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p> <p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
</div> </div>
<Link <Link
@@ -244,14 +305,14 @@ export const AppLayout: React.FC = () => {
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer" className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
> >
<LogOut className="w-3.5 h-3.5" /> <LogOut className="w-3.5 h-3.5" />
<span>Sign Out via Identity Server</span> <span>Sign Out of Session</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
</header> </header>
{/* Viewport with Landing Page ambient dot grid and glow */} {/* Main Content Area */}
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100"> <main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
<div <div
className="absolute inset-0 pointer-events-none opacity-15" className="absolute inset-0 pointer-events-none opacity-15"
@@ -271,7 +332,10 @@ export const AppLayout: React.FC = () => {
{/* Bottom Status Footer */} {/* Bottom Status Footer */}
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0"> <footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
<span>Project Financial Management (PFM) System</span> <span>Project Financial Management (PFM) System</span>
<span className="text-slate-400 font-medium">Duende STS OIDC Secure Session</span> <span className="text-slate-400 font-medium flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
Enterprise Secure Session
</span>
</footer> </footer>
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
import React, { useId } from 'react';
export interface BrandLogoProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number;
variant?: 'icon-only' | 'full';
showGlow?: boolean;
className?: string;
animateOnHover?: boolean;
}
const sizeMap = {
xs: 20,
sm: 28,
md: 36,
lg: 44,
xl: 56,
};
/**
* SVG brand logo component for Post Fund Management (PFM).
*/
export const BrandLogo: React.FC<BrandLogoProps> = ({
size = 'md',
variant = 'icon-only',
showGlow = true,
className = '',
animateOnHover = true,
}) => {
const uniqueId = useId().replace(/:/g, '');
const pixelSize = typeof size === 'number' ? size : sizeMap[size] || 36;
const glowId = `pfm-ambient-${uniqueId}`;
const topGradId = `pfm-top-${uniqueId}`;
const leftGradId = `pfm-left-${uniqueId}`;
const rightGradId = `pfm-right-${uniqueId}`;
const coreGradId = `pfm-core-${uniqueId}`;
const filterId = `pfm-filter-${uniqueId}`;
return (
<div
className={`inline-flex items-center gap-3 select-none ${
animateOnHover ? 'group cursor-pointer' : ''
} ${className}`}
>
<div
style={{ width: pixelSize, height: pixelSize }}
className={`relative shrink-0 flex items-center justify-center transition-transform duration-300 ${
animateOnHover ? 'group-hover:scale-105' : ''
}`}
>
{showGlow && (
<div
className="absolute inset-0 rounded-2xl bg-blue-500/20 blur-md pointer-events-none transition-opacity duration-300 group-hover:opacity-100 opacity-60"
/>
)}
<svg
viewBox="0 0 64 64"
width={pixelSize}
height={pixelSize}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="relative z-10 w-full h-full drop-shadow-sm"
>
<defs>
{/* Ambient Radial Glow */}
<radialGradient id={glowId} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.4" />
<stop offset="70%" stopColor="#2563eb" stopOpacity="0.12" />
<stop offset="100%" stopColor="#1e1b4b" stopOpacity="0" />
</radialGradient>
{/* Top facet: Governance & Portfolios (Indigo / Radiant Violet) */}
<linearGradient id={topGradId} x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#a5b4fc" />
<stop offset="35%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#4338ca" />
</linearGradient>
{/* Left facet: Capital & Disbursement Vault (Electric Sapphire) */}
<linearGradient id={leftGradId} x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#38bdf8" />
<stop offset="45%" stopColor="#2563eb" />
<stop offset="100%" stopColor="#1e40af" />
</linearGradient>
{/* Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) */}
<linearGradient id={rightGradId} x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="50%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
{/* Center Radiant Diamond */}
<linearGradient id={coreGradId} x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ffffff" />
<stop offset="40%" stopColor="#e0f2fe" />
<stop offset="100%" stopColor="#38bdf8" />
</linearGradient>
{/* Core luminous glow filter */}
<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Deep Contrast Foundation Disk */}
<circle cx="32" cy="32" r="31" fill="#090d16" />
<circle cx="32" cy="32" r="30" stroke="#1e293b" strokeWidth="1" />
<circle cx="32" cy="32" r="28" fill={`url(#${glowId})`} />
{/* Outer Hexagonal Structural Shield Outline */}
<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"
strokeOpacity="0.35"
strokeWidth="1.2"
fill="none"
/>
{/* Facet 1: Top Canopy (Governance / Oversight Architecture) */}
<path
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
fill={`url(#${topGradId})`}
/>
{/* Facet 2: Left Vault (Fund Management & Capital Allocation) */}
<path
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
fill={`url(#${leftGradId})`}
/>
{/* Facet 3: Right Wing (Milestone Performance & Impact Assurance) */}
<path
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
fill={`url(#${rightGradId})`}
/>
{/* Center Geometric Hub (The PFM Nexus Plate) */}
<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"
strokeWidth="1"
/>
{/* Kinetic Stepped Prisms */}
<path
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
fill={`url(#${topGradId})`}
opacity="0.9"
/>
<path
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
fill={`url(#${rightGradId})`}
opacity="0.9"
/>
{/* Center Radiant Milestone Diamond */}
<polygon
points="32,27 36.5,32 32,37 27.5,32"
fill={`url(#${coreGradId})`}
filter={`url(#${filterId})`}
/>
{/* Center Pure White Optical Star Point */}
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
</svg>
</div>
{variant === 'full' && (
<div className="flex flex-col min-w-0">
<span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
PFM Console
</span>
<span className="text-[11px] text-blue-400 font-medium tracking-tight">
Post-Fund Governance
</span>
</div>
)}
</div>
);
};
@@ -0,0 +1,237 @@
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Info, X } from 'lucide-react';
export interface HelpTooltipProps {
/** Concise context-sensitive explanation of the section, control, metric, or dialog */
content: ReactNode;
/** Optional title for the tooltip popover */
title?: string;
/** Custom alignment: top, bottom, left, right (defaults to top) */
placement?: 'top' | 'bottom' | 'left' | 'right';
/** Size of the (i) icon: sm (13px), md (15px), lg (18px) */
size?: 'sm' | 'md' | 'lg';
/** Additional styling classes for the container or trigger button */
className?: string;
/** Accessible label or ARIA label */
ariaLabel?: string;
}
export const HelpTooltip: React.FC<HelpTooltipProps> = ({
content,
title,
placement = 'top',
size = 'md',
className = '',
ariaLabel = 'Information and guidance',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const triggerRect = buttonRef.current.getBoundingClientRect();
const tooltipEl = tooltipRef.current;
const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288;
const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const PADDING = 12;
const GAP = 8;
const spaceAbove = triggerRect.top - GAP - PADDING;
const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING;
const spaceLeft = triggerRect.left - GAP - PADDING;
const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING;
let chosenPlacement = placement;
if (placement === 'bottom') {
if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) {
chosenPlacement = 'top';
}
} else if (placement === 'top') {
if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) {
chosenPlacement = 'bottom';
}
} else if (placement === 'left') {
if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) {
chosenPlacement = 'right';
}
} else if (placement === 'right') {
if (spaceRight < tooltipWidth && spaceLeft > spaceRight) {
chosenPlacement = 'left';
}
}
let top = 0;
let left = 0;
if (chosenPlacement === 'top') {
top = triggerRect.top - tooltipHeight - GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'bottom') {
top = triggerRect.bottom + GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'left') {
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.left - tooltipWidth - GAP;
} else {
// right
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.right + GAP;
}
// Viewport boundary clamping to guarantee tooltip is never cropped by screen edges
const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING);
left = Math.max(PADDING, Math.min(left, maxLeft));
const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING);
top = Math.max(PADDING, Math.min(top, maxTop));
setCoords({ top, left });
}, [placement]);
// Position updates on open, resize, or scroll of any container
useLayoutEffect(() => {
if (!isOpen) {
setCoords(null);
return;
}
updatePosition();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) {
resizeObserver = new ResizeObserver(() => {
updatePosition();
});
resizeObserver.observe(tooltipRef.current);
}
window.addEventListener('resize', updatePosition, { passive: true });
window.addEventListener('scroll', updatePosition, { capture: true, passive: true });
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, updatePosition]);
// Close on Escape or click outside both button and tooltip
useEffect(() => {
if (!isOpen) return;
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
const target = e.target as Node;
if (
buttonRef.current &&
!buttonRef.current.contains(target) &&
tooltipRef.current &&
!tooltipRef.current.contains(target)
) {
setIsOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('touchstart', handleOutsideClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('touchstart', handleOutsideClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const sizeClasses = {
sm: 'w-3.5 h-3.5 text-[11px]',
md: 'w-4 h-4 text-xs',
lg: 'w-5 h-5 text-sm',
}[size];
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-3.5 h-3.5',
lg: 'w-4 h-4',
}[size];
const tooltipPortal =
isOpen && typeof document !== 'undefined'
? createPortal(
<div
ref={tooltipRef}
role="tooltip"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: coords ? `${coords.top}px` : '-9999px',
left: coords ? `${coords.left}px` : '-9999px',
visibility: coords ? 'visible' : 'hidden',
}}
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>,
document.body
)
: null;
return (
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
<button
ref={buttonRef}
type="button"
aria-label={ariaLabel}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
setIsOpen((prev) => !prev);
}}
className={`inline-flex items-center justify-center rounded-full text-slate-400 hover:text-blue-400 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/60 hover:border-blue-500/50 transition-all cursor-pointer shadow-xs focus:outline-none focus:ring-1 focus:ring-blue-500/60 ${sizeClasses} p-0.5`}
title="Click for context guidance"
>
<Info className={`${iconSizes} shrink-0`} />
</button>
{tooltipPortal}
</div>
);
};
@@ -1,11 +1,13 @@
import React, { ReactNode, useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title: string; title: string;
subtitle?: string; subtitle?: string;
helpText?: ReactNode;
children: ReactNode; children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
} }
@@ -15,6 +17,7 @@ export const Modal: React.FC<ModalProps> = ({
onClose, onClose,
title, title,
subtitle, subtitle,
helpText,
children, children,
maxWidth = 'lg', maxWidth = 'lg',
}) => { }) => {
@@ -22,13 +25,19 @@ export const Modal: React.FC<ModalProps> = ({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
}; };
const handleDismissSubordinates = () => {
onClose();
};
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
window.addEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
} }
return () => { return () => {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
@@ -50,7 +59,17 @@ export const Modal: React.FC<ModalProps> = ({
> >
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90"> <div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
<div> <div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3> <h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
{helpText && (
<HelpTooltip
title={`${title} Guidance`}
content={helpText}
size="sm"
placement="bottom"
/>
)}
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div> </div>
<button <button
@@ -0,0 +1,89 @@
import React from 'react';
import { AlertCircle, AlertTriangle, X } from 'lucide-react';
import { parseFriendlyError } from '../../utils/apiErrorParser';
interface ProblemDetailsAlertProps {
error: unknown;
onDismiss?: () => void;
title?: string;
className?: string;
}
export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
error,
onDismiss,
title,
className = '',
}) => {
if (!error) return null;
const parsed = parseFriendlyError(error, title || 'Action Could Not Be Completed');
return (
<div
role="alert"
className={`rounded-xl border border-rose-500/40 bg-[#160d14] p-4 text-gray-200 shadow-lg shadow-rose-950/20 ${className}`}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight">
{parsed.title}
</h4>
{parsed.categoryLabel && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded border bg-rose-950/60 text-rose-300 border-rose-800/60">
{parsed.categoryLabel}
</span>
)}
{parsed.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {parsed.statusCode}
</span>
)}
</div>
{/* Direct error message or error list from ProblemDetails */}
{parsed.errorsList && parsed.errorsList.length > 0 ? (
<div className="mt-2 space-y-1.5">
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
)}
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
</div>
) : parsed.message ? (
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
) : null}
{parsed.technicalDetails && (
<p className="text-[10px] font-mono text-slate-500 mt-2">
Ref: {parsed.technicalDetails}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,603 @@
import React, { useState, useMemo } from 'react';
import { Risk, Project } from '../../types';
import { getUserEmail } from '../../lib/formatters';
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface RiskHeatmapMatrixProps {
risks: Risk[];
projects?: Project[];
compact?: boolean;
onSelectRisk?: (risk: Risk) => void;
title?: string;
subtitle?: string;
}
// Standard 5-point likelihood and impact levels
const LIKELIHOOD_LEVELS = [
{ val: 1, label: 'Rare', short: '1 - Rare' },
{ val: 2, label: 'Unlikely', short: '2 - Unlikely' },
{ val: 3, label: 'Possible', short: '3 - Possible' },
{ val: 4, label: 'Likely', short: '4 - Likely' },
{ val: 5, label: 'Almost Certain', short: '5 - Almost Certain' },
];
const IMPACT_LEVELS = [
{ val: 5, label: 'Severe', short: '5 - Severe' },
{ val: 4, label: 'Critical', short: '4 - Critical' },
{ val: 3, label: 'Major', short: '3 - Major' },
{ val: 2, label: 'Moderate', short: '2 - Moderate' },
{ val: 1, label: 'Minor', short: '1 - Minor' },
];
export const getCellSeverity = (score: number) => {
if (score >= 15) {
return {
name: 'Critical',
bgClass: 'bg-rose-950/70 border-rose-600/70 text-rose-100 hover:bg-rose-900/90',
badgeClass: 'bg-rose-500/20 text-rose-300 border-rose-500/40',
textColor: 'text-rose-400',
};
}
if (score >= 10) {
return {
name: 'High',
bgClass: 'bg-orange-950/60 border-orange-700/60 text-orange-200 hover:bg-orange-900/80',
badgeClass: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
textColor: 'text-orange-400',
};
}
if (score >= 5) {
return {
name: 'Moderate',
bgClass: 'bg-amber-950/50 border-amber-800/50 text-amber-300 hover:bg-amber-900/70',
badgeClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
textColor: 'text-amber-400',
};
}
return {
name: 'Low',
bgClass: 'bg-emerald-950/40 border-emerald-800/40 text-emerald-300 hover:bg-emerald-900/60',
badgeClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
textColor: 'text-emerald-400',
};
};
export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
risks,
projects = [],
compact = false,
onSelectRisk,
title,
subtitle,
}) => {
const [matrixMode, setMatrixMode] = useState<'5x5' | '3x3'>('5x5');
const [selectedCell, setSelectedCell] = useState<{
impactLabel: string;
likelihoodLabel: string;
score: number;
risks: Risk[];
} | null>(null);
// Map project names for easy lookup
const projectMap = useMemo(() => {
const map: Record<string, string> = {};
projects.forEach((p) => {
map[String(p.id)] = p.name;
});
return map;
}, [projects]);
// Pre-calculate risk counts by exact coordinates (1-5)
const matrix5x5 = useMemo(() => {
const grid: Record<string, Risk[]> = {};
for (let imp = 1; imp <= 5; imp++) {
for (let lik = 1; lik <= 5; lik++) {
grid[`${imp}-${lik}`] = [];
}
}
risks.forEach((r) => {
const imp = Math.min(5, Math.max(1, Number(r.impact) || 1));
const lik = Math.min(5, Math.max(1, Number(r.likelihood) || 1));
grid[`${imp}-${lik}`].push(r);
});
return grid;
}, [risks]);
// Pre-calculate 3x3 summary matrix
const matrix3x3 = useMemo(() => {
const buckets: Record<string, Risk[]> = {
'high-low': [],
'high-med': [],
'high-high': [],
'med-low': [],
'med-med': [],
'med-high': [],
'low-low': [],
'low-med': [],
'low-high': [],
};
risks.forEach((r) => {
const imp = Number(r.impact) || 1;
const lik = Number(r.likelihood) || 1;
const impKey = imp >= 4 ? 'high' : imp === 3 ? 'med' : 'low';
const likKey = lik >= 4 ? 'high' : lik === 3 ? 'med' : 'low';
buckets[`${impKey}-${likKey}`].push(r);
});
return buckets;
}, [risks]);
// Overall severity summary counters
const summaryCounters = useMemo(() => {
let low = 0;
let moderate = 0;
let high = 0;
let critical = 0;
risks.forEach((r) => {
const score = (Number(r.impact) || 1) * (Number(r.likelihood) || 1);
if (score >= 15) critical++;
else if (score >= 10) high++;
else if (score >= 5) moderate++;
else low++;
});
return { low, moderate, high, critical };
}, [risks]);
const handleCellClick = (impactLabel: string, likelihoodLabel: string, score: number, cellRisks: Risk[]) => {
if (cellRisks.length === 0) {
if (selectedCell) setSelectedCell(null);
return;
}
setSelectedCell({
impactLabel,
likelihoodLabel,
score,
risks: cellRisks,
});
};
return (
<div className="space-y-4">
{/* Header & Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
<HelpTooltip
title="Risk Heatmap Guidance"
content="Visualizes project risk exposure based on ISO 31000 standards. Severity score = Likelihood (1–5) × Impact (1–5). Click any populated cell to drill into registered risks."
size="sm"
placement="bottom"
/>
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{/* Mode Switcher */}
<div className="flex items-center bg-slate-900/90 p-0.5 rounded-lg border border-slate-700/80 text-[11px]">
<button
type="button"
onClick={() => {
setMatrixMode('5x5');
setSelectedCell(null);
}}
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
matrixMode === '5x5' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
}`}
>
5×5 Exact Matrix
</button>
<button
type="button"
onClick={() => {
setMatrixMode('3x3');
setSelectedCell(null);
}}
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
matrixMode === '3x3' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
}`}
>
3×3 Summary
</button>
</div>
<HelpTooltip
title="Matrix Resolution Switcher"
content="Toggle between 5×5 detailed granular risk distribution and 3×3 executive aggregated categories (Low, Medium, High)."
size="sm"
placement="left"
/>
</div>
</div>
{/* Summary Score Badges */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-rose-500/10 border border-rose-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-rose-400 font-semibold block uppercase">Critical (15–25)</span>
<span className="text-base font-bold text-rose-200">{summaryCounters.critical}</span>
</div>
<div className="bg-orange-500/10 border border-orange-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-orange-400 font-semibold block uppercase">High (10–14)</span>
<span className="text-base font-bold text-orange-200">{summaryCounters.high}</span>
</div>
<div className="bg-amber-500/10 border border-amber-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-amber-400 font-semibold block uppercase">Moderate (5–9)</span>
<span className="text-base font-bold text-amber-200">{summaryCounters.moderate}</span>
</div>
<div className="bg-emerald-500/10 border border-emerald-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-emerald-400 font-semibold block uppercase">Low (1–4)</span>
<span className="text-base font-bold text-emerald-200">{summaryCounters.low}</span>
</div>
</div>
{/* 5x5 Matrix Mode */}
{matrixMode === '5x5' && (
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
<div className="min-w-[480px]">
{/* Top axis label: Likelihood */}
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
Likelihood / Probability &rarr;
</div>
<div className="flex">
{/* Left Y-Axis Label: Impact */}
<div className="w-6 flex items-center justify-center -mr-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
&uarr; Impact / Severity
</span>
</div>
{/* Grid with Row Headers */}
<div className="flex-1">
{/* Column Headers */}
<div className="grid grid-cols-6 gap-1.5 mb-1.5 text-center">
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
Impact \ Lik.
</div>
{LIKELIHOOD_LEVELS.map((lik) => (
<div key={lik.val} className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 py-1 rounded-md">
{lik.short}
</div>
))}
</div>
{/* Rows (Impact 5 down to 1) */}
<div className="space-y-1.5">
{IMPACT_LEVELS.map((imp) => (
<div key={imp.val} className="grid grid-cols-6 gap-1.5">
{/* Row Header */}
<div className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 rounded-md flex items-center justify-end pr-2">
{imp.short}
</div>
{/* 5 Cells */}
{LIKELIHOOD_LEVELS.map((lik) => {
const cellRisks = matrix5x5[`${imp.val}-${lik.val}`] || [];
const score = imp.val * lik.val;
const sev = getCellSeverity(score);
const hasRisks = cellRisks.length > 0;
const isSelected =
selectedCell?.impactLabel === imp.label && selectedCell?.likelihoodLabel === lik.label;
return (
<button
key={lik.val}
type="button"
onClick={() => handleCellClick(imp.label, lik.label, score, cellRisks)}
className={`relative rounded-xl border p-2 text-center transition-all cursor-pointer ${
sev.bgClass
} ${isSelected ? 'ring-2 ring-blue-400 scale-[1.03] z-10 shadow-lg' : ''} ${
hasRisks ? 'shadow-xs font-bold' : 'opacity-70 hover:opacity-100'
}`}
title={`Impact: ${imp.label} (${imp.val}) × Likelihood: ${lik.label} (${lik.val}) = Score ${score} (${sev.name})`}
>
{/* Score small indicator */}
<span className="text-[9px] block text-slate-400/80 font-mono">
{score}
</span>
{/* Risk count or empty dash */}
{hasRisks ? (
<div className="mt-0.5 flex items-center justify-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse"></span>
<span className="text-xs font-extrabold text-white px-1.5 py-0.2 rounded-full bg-black/40 border border-white/20">
{cellRisks.length} {cellRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</div>
) : (
<span className="text-xs text-slate-600 block mt-0.5">&mdash;</span>
)}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* 3x3 Summary Mode */}
{matrixMode === '3x3' && (
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
<div className="min-w-[440px]">
{/* Top axis label: Likelihood */}
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
Likelihood / Probability &rarr;
</div>
<div className="flex">
{/* Left Y-Axis Label: Impact */}
<div className="w-6 flex items-center justify-center -mr-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
&uarr; Impact / Severity
</span>
</div>
{/* 3x3 Table */}
<div className="flex-1">
{/* Column Headers */}
<div className="grid grid-cols-4 gap-2 mb-2 text-center">
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
Impact \ Lik.
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
Low (1&ndash;2 Rare/Unlikely)
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
Medium (3 Possible)
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
High (4&ndash;5 Likely/Certain)
</div>
</div>
{/* Row 1: High Impact (4-5) */}
<div className="grid grid-cols-4 gap-2 mb-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
High (4&ndash;5 Major/Crit.)
</div>
{/* High Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['high-low'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'Low (1-2)', 8, cRisks)}
className="bg-amber-950/50 border border-amber-800/60 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* High Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['high-med'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'Medium (3)', 12, cRisks)}
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* High Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['high-high'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'High (4-5)', 20, cRisks)}
className="bg-rose-950/80 border border-rose-600 p-3 rounded-xl text-center hover:bg-rose-900 transition cursor-pointer shadow-md"
>
<span className="text-[10px] font-bold text-rose-300 block">Critical Severity</span>
<span className="text-xs font-extrabold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
{/* Row 2: Medium Impact (3) */}
<div className="grid grid-cols-4 gap-2 mb-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
Medium (3 Moderate)
</div>
{/* Med Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['med-low'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'Low (1-2)', 3, cRisks)}
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Med Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['med-med'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'Medium (3)', 9, cRisks)}
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Med Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['med-high'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'High (4-5)', 12, cRisks)}
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
{/* Row 3: Low Impact (1-2) */}
<div className="grid grid-cols-4 gap-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
Low (1&ndash;2 Minor)
</div>
{/* Low Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['low-low'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'Low (1-2)', 1, cRisks)}
className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl text-center hover:bg-slate-800/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-slate-400 block">Trivial Severity</span>
<span className="text-xs font-bold text-slate-300 mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Low Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['low-med'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'Medium (3)', 3, cRisks)}
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Low Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['low-high'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'High (4-5)', 8, cRisks)}
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
</div>
</div>
</div>
</div>
)}
{/* Selected Cell Inspector (Drill-down) */}
{selectedCell && selectedCell.risks.length > 0 && (
<div className="bg-[#0f1523] border border-blue-500/40 p-4 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-blue-500/20 text-blue-400">
<Info className="w-4 h-4" />
</div>
<div>
<h4 className="text-xs font-bold text-white">
Risks in Box: Impact [{selectedCell.impactLabel}] &times; Likelihood [{selectedCell.likelihoodLabel}]
</h4>
<p className="text-[10px] text-slate-400">
Residual Score: <strong className="text-blue-300">{selectedCell.score} / 25</strong> &bull; Total Items: {selectedCell.risks.length}
</p>
</div>
</div>
<button
type="button"
onClick={() => setSelectedCell(null)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="mt-3 space-y-2 max-h-56 overflow-y-auto pr-1 divide-y divide-slate-800/60">
{selectedCell.risks.map((rk) => (
<div
key={rk.id}
onClick={() => onSelectRisk && onSelectRisk(rk)}
className={`pt-2 first:pt-0 ${onSelectRisk ? 'cursor-pointer hover:bg-slate-800/30 p-2 rounded-xl transition' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-xs text-white line-clamp-1">{rk.name}</span>
<span className="text-[10px] font-mono text-slate-400 shrink-0">
Project #{rk.projectId} {projectMap[String(rk.projectId)] ? `(${projectMap[String(rk.projectId)]})` : ''}
</span>
</div>
{rk.description && (
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{rk.description}</p>
)}
<div className="flex items-center gap-3 mt-1 text-[10px] text-slate-400">
<span>Likelihood: <strong className="text-slate-200">{rk.likelihood}</strong></span>
<span>Impact: <strong className="text-slate-200">{rk.impact}</strong></span>
<span>Owner: <strong className="text-slate-300">{getUserEmail(rk.owner)}</strong></span>
</div>
{rk.mitigationAction && (
<div className="mt-1 text-[10px] bg-slate-900/80 p-1.5 rounded-lg text-slate-300 border border-slate-800">
<strong className="text-blue-400">Mitigation: </strong>{rk.mitigationAction}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
@@ -1,24 +1,247 @@
import React, { createContext, useContext, useState, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react'; import {
CheckCircle2,
AlertCircle,
Info,
X,
Copy,
Check,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser';
export type ToastType = 'success' | 'error' | 'info'; export type ToastType = 'success' | 'error' | 'info';
interface ToastItem { export interface ToastItem {
id: string; id: string;
type: ToastType; type: ToastType;
title: string; title: string;
message?: string; message?: string;
duration: number; // in milliseconds
createdAt: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
export interface ToastOptions {
duration?: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
} }
interface ToastContextType { interface ToastContextType {
toast: (type: ToastType, title: string, message?: string) => void; toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void;
success: (title: string, message?: string) => void; success: (title: string, message?: string, options?: ToastOptions) => void;
error: (title: string, message?: string) => void; error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void;
info: (title: string, message?: string) => void; info: (title: string, message?: string, options?: ToastOptions) => void;
} }
const ToastContext = createContext<ToastContextType | undefined>(undefined); const ToastContext = createContext<ToastContextType | undefined>(undefined);
// Default durations (in ms):
// Errors linger significantly longer (14s) so users have sufficient time to read and note the failure.
const DEFAULT_DURATIONS: Record<ToastType, number> = {
success: 4500,
info: 6000,
error: 14000,
};
const ToastCard: React.FC<{
toast: ToastItem;
onRemove: (id: string) => void;
}> = ({ toast, onRemove }) => {
const [isPaused, setIsPaused] = useState(false);
const [remainingTime, setRemainingTime] = useState(toast.duration);
const [copied, setCopied] = useState(false);
const [showTechnical, setShowTechnical] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (isPaused) {
if (intervalRef.current) clearInterval(intervalRef.current);
return;
}
const step = 100;
intervalRef.current = setInterval(() => {
setRemainingTime((prev) => {
if (prev <= step) {
if (intervalRef.current) clearInterval(intervalRef.current);
onRemove(toast.id);
return 0;
}
return prev - step;
});
}, step);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPaused, toast.id, onRemove]);
const handleCopy = () => {
const textToCopy = [
`PFM Error Notice: ${toast.title}`,
toast.message ? `Details: ${toast.message}` : '',
toast.statusCode ? `Status Code: ${toast.statusCode}` : '',
toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '',
`Timestamp: ${new Date(toast.createdAt).toISOString()}`,
]
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100));
return (
<div
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${
toast.type === 'success'
? 'border-emerald-500/30 text-gray-200'
: toast.type === 'error'
? 'border-rose-500/40 text-gray-200 shadow-rose-950/20'
: 'border-blue-500/30 text-gray-200'
}`}
>
<div className="flex items-start gap-3 p-4">
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight leading-snug">{toast.title}</h4>
{toast.categoryLabel && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${
toast.category === 'validation'
? 'bg-amber-950/60 text-amber-300 border-amber-800/60'
: toast.category === 'creation'
? 'bg-indigo-950/60 text-indigo-300 border-indigo-800/60'
: toast.category === 'update'
? 'bg-sky-950/60 text-sky-300 border-sky-800/60'
: toast.category === 'security'
? 'bg-rose-950/60 text-rose-300 border-rose-800/60'
: toast.category === 'notFound'
? 'bg-slate-800 text-slate-300 border-slate-700'
: toast.category === 'conflict'
? 'bg-purple-950/60 text-purple-300 border-purple-800/60'
: 'bg-slate-800 text-slate-300 border-slate-700'
}`}
>
{toast.categoryLabel}
</span>
)}
{toast.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {toast.statusCode}
</span>
)}
</div>
{/* Display errors from ProblemDetails errors member */}
{toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-1.5 space-y-1">
{toast.message && !toast.errorsList.includes(toast.message) && (
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
)}
<ul className="space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
</div>
) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
</p>
) : null}
{/* Optional collapsible technical reference */}
{toast.technicalDetails && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80">
<button
type="button"
onClick={() => setShowTechnical(!showTechnical)}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
>
<span>{showTechnical ? 'Hide Technical Reference' : 'Show Technical Reference'}</span>
{showTechnical ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showTechnical && (
<div className="mt-1.5 p-2 rounded bg-[#090d16] border border-slate-800 font-mono text-[10px] text-slate-400 break-all select-all">
{toast.technicalDetails}
</div>
)}
</div>
)}
{/* Action bar for error toasts (Copy info & linger helper) */}
{toast.type === 'error' && (
<div className="mt-3 flex items-center justify-between gap-2 pt-2 border-t border-slate-800/60 text-[11px] text-slate-400">
<span className="text-[10px] text-slate-500">
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors cursor-pointer"
title="Copy details to clipboard for support or administrator"
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
<span>{copied ? 'Copied' : 'Copy Details'}</span>
</button>
</div>
)}
</div>
<button
onClick={() => onRemove(toast.id)}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
title="Dismiss notification"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Visual countdown progress bar */}
<div className="w-full bg-slate-800/40 h-1 overflow-hidden">
<div
className={`h-full transition-all duration-100 ease-linear ${
toast.type === 'success'
? 'bg-emerald-500'
: toast.type === 'error'
? 'bg-rose-500'
: 'bg-blue-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
);
};
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]); const [toasts, setToasts] = useState<ToastItem[]>([]);
@@ -26,44 +249,91 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
setToasts((prev) => prev.filter((t) => t.id !== id)); setToasts((prev) => prev.filter((t) => t.id !== id));
}; };
const addToast = (type: ToastType, title: string, message?: string) => { const addToast = (
type: ToastType,
title: string,
message?: string,
options?: ToastOptions
) => {
const id = Math.random().toString(36).substring(2, 9); const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, title, message }]); const duration = options?.duration || DEFAULT_DURATIONS[type];
setTimeout(() => removeToast(id), 5000);
setToasts((prev) => [
...prev,
{
id,
type,
title,
message,
duration,
createdAt: Date.now(),
statusCode: options?.statusCode,
category: options?.category,
categoryLabel: options?.categoryLabel,
technicalDetails: options?.technicalDetails,
fieldErrors: options?.fieldErrors,
errorsList: options?.errorsList,
},
]);
}; };
const success = (title: string, message?: string) => addToast('success', title, message); const success = (title: string, message?: string, options?: ToastOptions) =>
const error = (title: string, message?: string) => addToast('error', title, message); addToast('success', title, message, options);
const info = (title: string, message?: string) => addToast('info', title, message);
const info = (title: string, message?: string, options?: ToastOptions) =>
addToast('info', title, message, options);
/**
* Intelligently formats errors using ProblemDetails and non-technical language.
* Differentiates BadRequest errors for input validation, creation failures, and update failures.
* Accepts flexible argument patterns:
* - error("Action Failed", err)
* - error("Action Failed", "Custom message")
* - error(err, "Action Failed")
* - error(err)
*/
const error = (
titleOrError: unknown,
messageOrError?: unknown,
options?: ToastOptions
) => {
let rawError: unknown = null;
let actionTitle = 'Action Failed';
if (typeof titleOrError === 'string') {
actionTitle = titleOrError;
rawError = messageOrError;
} else {
rawError = titleOrError;
if (typeof messageOrError === 'string') {
actionTitle = messageOrError;
}
}
// Parse friendly non-technical error with ProblemDetails awareness
const parsed = parseFriendlyError(rawError, actionTitle);
addToast('error', parsed.title, parsed.message, {
duration: options?.duration || DEFAULT_DURATIONS.error,
statusCode: parsed.statusCode || options?.statusCode,
category: options?.category || parsed.category,
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: parsed.errorsList || options?.errorsList,
});
};
return ( return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}> <ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{children} {children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none p-2">
{toasts.map((t) => (
<div <div
key={t.id} role="region"
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-2xl transition-all duration-200 bg-[#141414] ${t.type === 'success' aria-label="System Notifications"
? 'border-green-500/30 text-gray-200' className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
: t.type === 'error'
? 'border-rose-500/30 text-gray-200'
: 'border-indigo-500/30 text-gray-200'
}`}
> >
{t.type === 'success' && <CheckCircle2 className="w-5 h-5 text-green-400 shrink-0 mt-0.5" />} {toasts.map((t) => (
{t.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />} <ToastCard key={t.id} toast={t} onRemove={removeToast} />
{t.type === 'info' && <Info className="w-5 h-5 text-indigo-400 shrink-0 mt-0.5" />}
<div className="flex-1">
<h4 className="text-sm font-semibold text-white">{t.title}</h4>
{t.message && <p className="text-xs mt-0.5 text-gray-400 leading-relaxed">{t.message}</p>}
</div>
<button
onClick={() => removeToast(t.id)}
className="text-gray-400 hover:text-white p-0.5 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))} ))}
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>
+367 -8
View File
@@ -1,36 +1,387 @@
import { updateHasherSettings } from './services/toolsService';
export interface SecuritySettings { export interface SecuritySettings {
Authority: string; Authority: string;
Audience: string; Audience: string;
ClientId: string; ClientId: string;
ClientSecret: string; ClientSecret: string;
Scope?: string;
} }
export interface ApiSettings { export interface ApiSettings {
BaseUrl: string; BaseUrl: string;
} }
export interface HasherSettings {
Salt: string;
MinHashLength: number;
}
export type ConfigSource =
| 'runtime-injected'
| 'runtime-fetched'
| 'build-env'
| 'fallback-defaults';
export interface AppConfig { export interface AppConfig {
SecuritySettings: SecuritySettings; SecuritySettings: SecuritySettings;
ApiSettings: ApiSettings; ApiSettings: ApiSettings;
HasherSettings: HasherSettings;
ConfigSource: ConfigSource;
LoadedAt?: string;
} }
// Fallback presets (used only when no runtime or build-time variable is provided)
export const defaultSecuritySettings: SecuritySettings = { export const defaultSecuritySettings: SecuritySettings = {
Authority: (import.meta.env.VITE_OIDC_AUTHORITY as string) || 'https://sts.security.khongisa.co.za', Authority: 'https://sts.security.khongisa.co.za',
Audience: (import.meta.env.VITE_OIDC_AUDIENCE as string) || 'pfm-api-dev', Audience: 'pfm-api-dev',
ClientId: (import.meta.env.VITE_OIDC_CLIENT_ID as string) || 'pfm-console-dev', ClientId: 'pfm-console-dev',
ClientSecret: (import.meta.env.VITE_OIDC_CLIENT_SECRET as string) || 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94', ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
Scope: 'openid profile roles email address pfm-api-dev',
}; };
export const defaultApiSettings: ApiSettings = { export const defaultApiSettings: ApiSettings = {
BaseUrl: (import.meta.env.VITE_API_BASE_URL as string) || 'https://api.ifri.khongisa.co.za/', BaseUrl: 'https://api.ifri.khongisa.co.za/',
}; };
const runtimeConfig: AppConfig = { export const defaultHasherSettings: HasherSettings = {
SecuritySettings: defaultSecuritySettings, Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
ApiSettings: defaultApiSettings, MinHashLength: 11,
}; };
/**
* Extracts first non-empty, trimmed string from candidate values.
*/
function pickFirstNonEmpty(...candidates: any[]): string | undefined {
for (const c of candidates) {
if (c !== undefined && c !== null) {
const s = String(c).trim();
if (s !== '' && s !== 'undefined' && s !== 'null') {
return s;
}
}
}
return undefined;
}
/**
* Normalizes an API base URL ensuring HTTPS protocol by default and a single trailing slash.
*/
function normalizeBaseUrl(url: string): string {
let trimmed = url.trim();
// Ensure HTTPS is default protocol if protocol is omitted
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed}`;
} else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) {
// Upgrade remote HTTP URLs to HTTPS by default
trimmed = trimmed.replace(/^http:\/\//i, 'https://');
}
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/**
* Normalizes an OIDC/STS Authority URL ensuring HTTPS protocol by default.
*/
function normalizeAuthorityUrl(url: string): string {
let trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed}`;
} else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) {
trimmed = trimmed.replace(/^http:\/\//i, 'https://');
}
return trimmed.replace(/\/+$/, '');
}
/**
* Reads any runtime config object injected into window.
* Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_
*/
function getWindowRuntimeConfig(): Record<string, any> {
if (typeof window === 'undefined') return {};
const win = window as any;
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
}
/**
* Parses and maps configuration from a given source object.
* Checks flat keys, nested keys, and ASP.NET double-underscore conventions.
*/
function extractSourceConfig(src: Record<string, any>): {
authority?: string;
audience?: string;
clientId?: string;
clientSecret?: string;
scope?: string;
baseUrl?: string;
hasherSalt?: string;
hasherMinLength?: number;
} {
if (!src || typeof src !== 'object') return {};
const authority = pickFirstNonEmpty(
src.VITE_OIDC_AUTHORITY,
src.OIDC_AUTHORITY,
src.SecuritySettings?.Authority,
src.SecuritySettings__Authority,
src.SecuritySettings_Authority,
src.AUTHORITY,
src.STS_AUTHORITY
);
const audience = pickFirstNonEmpty(
src.VITE_OIDC_AUDIENCE,
src.OIDC_AUDIENCE,
src.SecuritySettings?.Audience,
src.SecuritySettings__Audience,
src.SecuritySettings_Audience,
src.AUDIENCE
);
const clientId = pickFirstNonEmpty(
src.VITE_OIDC_CLIENT_ID,
src.OIDC_CLIENT_ID,
src.SecuritySettings?.ClientId,
src.SecuritySettings__ClientId,
src.SecuritySettings_ClientId,
src.CLIENT_ID
);
const clientSecret = pickFirstNonEmpty(
src.VITE_OIDC_CLIENT_SECRET,
src.OIDC_CLIENT_SECRET,
src.SecuritySettings?.ClientSecret,
src.SecuritySettings__ClientSecret,
src.SecuritySettings_ClientSecret,
src.CLIENT_SECRET
);
const scope = pickFirstNonEmpty(
src.VITE_OIDC_SCOPE,
src.OIDC_SCOPE,
src.SecuritySettings?.Scope,
src.SecuritySettings__Scope,
src.SecuritySettings_Scope,
src.SCOPE
);
const baseUrl = pickFirstNonEmpty(
src.VITE_API_BASE_URL,
src.API_BASE_URL,
src.ApiSettings?.BaseUrl,
src.ApiSettings__BaseUrl,
src.ApiSettings_BaseUrl,
src.PFM_API_BASE_URL
);
const hasherSalt = pickFirstNonEmpty(
src.VITE_HASHER_SALT,
src.HASHER_SALT,
src.HasherSettings?.Salt,
src.HasherSettings__Salt,
src.HasherSettings_Salt
);
const rawMinLength = pickFirstNonEmpty(
src.VITE_HASHER_MIN_LENGTH,
src.HASHER_MIN_LENGTH,
src.HasherSettings?.MinHashLength,
src.HasherSettings__MinLength,
src.HasherSettings_MinLength
);
const hasherMinLength = rawMinLength ? Number(rawMinLength) : undefined;
return {
authority,
audience,
clientId,
clientSecret,
scope,
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
hasherSalt,
hasherMinLength: hasherMinLength && !isNaN(hasherMinLength) && hasherMinLength > 0 ? hasherMinLength : undefined,
};
}
/**
* Builds the prioritized configuration by evaluating:
* Tier 1: Injected Runtime Config (window.__RUNTIME_CONFIG__ from docker-entrypoint.sh)
* Tier 2: Fetched Runtime Config (/config.json from server)
* Tier 3: Vite Build-Time Variables (import.meta.env.VITE_*)
* Tier 4: Fallback Defaults
*/
function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
const windowConfig = extractSourceConfig(getWindowRuntimeConfig());
const fetchedConfig = fetchedJson ? extractSourceConfig(fetchedJson) : {};
const buildEnvConfig = extractSourceConfig(
(typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record<string, any>
);
// Check if any runtime deployment override was provided
const hasInjected = Object.values(windowConfig).some((v) => v !== undefined);
const hasFetched = Object.values(fetchedConfig).some((v) => v !== undefined);
const hasBuildEnv = Object.values(buildEnvConfig).some((v) => v !== undefined);
let source: ConfigSource = 'fallback-defaults';
if (hasInjected) {
source = 'runtime-injected';
} else if (hasFetched) {
source = 'runtime-fetched';
} else if (hasBuildEnv) {
source = 'build-env';
}
const authority = pickFirstNonEmpty(
windowConfig.authority,
fetchedConfig.authority,
buildEnvConfig.authority,
defaultSecuritySettings.Authority
)!;
const audience = pickFirstNonEmpty(
windowConfig.audience,
fetchedConfig.audience,
buildEnvConfig.audience,
defaultSecuritySettings.Audience
)!;
const clientId = pickFirstNonEmpty(
windowConfig.clientId,
fetchedConfig.clientId,
buildEnvConfig.clientId,
defaultSecuritySettings.ClientId
)!;
const clientSecret = pickFirstNonEmpty(
windowConfig.clientSecret,
fetchedConfig.clientSecret,
buildEnvConfig.clientSecret,
defaultSecuritySettings.ClientSecret
)!;
const scope = pickFirstNonEmpty(
windowConfig.scope,
fetchedConfig.scope,
buildEnvConfig.scope,
defaultSecuritySettings.Scope
);
const baseUrl = pickFirstNonEmpty(
windowConfig.baseUrl,
fetchedConfig.baseUrl,
buildEnvConfig.baseUrl,
defaultApiSettings.BaseUrl
)!;
const salt = pickFirstNonEmpty(
windowConfig.hasherSalt,
fetchedConfig.hasherSalt,
buildEnvConfig.hasherSalt,
defaultHasherSettings.Salt
)!;
const minHashLength =
windowConfig.hasherMinLength ||
fetchedConfig.hasherMinLength ||
buildEnvConfig.hasherMinLength ||
defaultHasherSettings.MinHashLength;
return {
SecuritySettings: {
Authority: normalizeAuthorityUrl(authority),
Audience: audience,
ClientId: clientId,
ClientSecret: clientSecret,
Scope: scope,
},
ApiSettings: {
BaseUrl: normalizeBaseUrl(baseUrl),
},
HasherSettings: {
Salt: salt,
MinHashLength: minHashLength,
},
ConfigSource: source,
LoadedAt: new Date().toISOString(),
};
}
// Initial synchronous computation from window.__RUNTIME_CONFIG__ and build-time env
let runtimeConfig: AppConfig = resolveConfig();
// Immediately apply deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
const configListeners: Array<(config: AppConfig) => void> = [];
export function onConfigChanged(listener: (config: AppConfig) => void): () => void {
configListeners.push(listener);
return () => {
const idx = configListeners.indexOf(listener);
if (idx !== -1) configListeners.splice(idx, 1);
};
}
let loadPromise: Promise<AppConfig> | null = null;
/**
* Asynchronously loads and verifies runtime configuration.
* Fetches /config.json with cache-busting to ensure latest deployment variables
* are prioritized over static build artifacts.
*/
export async function loadRuntimeConfig(): Promise<AppConfig> { export async function loadRuntimeConfig(): Promise<AppConfig> {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
let fetchedJson: Record<string, any> | undefined;
if (typeof window !== 'undefined') {
try {
// Fetch /config.json with cache-busting to bypass any intermediary caching
const res = await fetch(`/config.json?_t=${Date.now()}`, {
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (res.ok) {
const data = await res.json();
if (data && typeof data === 'object') {
fetchedJson = data;
}
}
} catch (err) {
// Fetching /config.json is non-blocking; window.__RUNTIME_CONFIG__ or env fallback will be used
console.debug('[Config] Note: Remote /config.json fetch bypassed:', err);
}
}
runtimeConfig = resolveConfig(fetchedJson);
// Synchronize deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
console.info(
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
);
// Notify registered listeners
configListeners.forEach((fn) => {
try {
fn(runtimeConfig);
} catch (e) {
console.error('[Config] Listener error:', e);
}
});
return runtimeConfig;
})();
return loadPromise;
}
export function getAppConfig(): AppConfig {
return runtimeConfig; return runtimeConfig;
} }
@@ -41,3 +392,11 @@ export function getSecuritySettings(): SecuritySettings {
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
return runtimeConfig.ApiSettings.BaseUrl; return runtimeConfig.ApiSettings.BaseUrl;
} }
export function getHasherSettings(): HasherSettings {
return runtimeConfig.HasherSettings;
}
export function getConfigSource(): ConfigSource {
return runtimeConfig.ConfigSource;
}
+24 -9
View File
@@ -1,14 +1,31 @@
@import "tailwindcss"; @import "tailwindcss";
@theme {
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
@layer base { @layer base {
:root { :root {
color-scheme: dark; color-scheme: dark;
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
} }
body { body {
background-color: #0a0a0a; background-color: #090d16;
color: #e5e7eb; color: #e2e8f0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input, select, textarea {
font-family: inherit;
}
.font-mono, code, kbd, samp, pre {
font-family: var(--font-mono) !important;
} }
/* Form control color scheme */ /* Form control color scheme */
@@ -21,16 +38,13 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0a0a0a; background: #0a0a0a;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #262626; background: #262626;
border-radius: 9999px; border-radius: 9999px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #3f3f46; background: #3f3f46;
} }
@@ -220,10 +234,10 @@ textarea {
border-color: #383838 !important; border-color: #383838 !important;
} }
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
color: #94a3b8 !important; color: #94a3b8 !important;
} }
/* Table header text high contrast */ /* Table header text high contrast */
th { th {
@@ -254,3 +268,4 @@ th {
.border-emerald-600 { .border-emerald-600 {
border-color: #3b82f6 !important; border-color: #3b82f6 !important;
} }
@@ -307,7 +307,52 @@ export const getContractStatusBadge = (status: ContractStatus | number) => {
} }
}; };
export const getActivityStatusBadge = (status: ActivityStatus | number) => { export const getActivityStatusBadge = (status: ActivityStatus | number | string | boolean | null | undefined) => {
if (status === null || status === undefined) {
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (typeof status === 'boolean') {
return status
? { label: 'Active', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold' }
: { label: 'Inactive', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
}
const str = String(status).trim().toLowerCase();
if (str === '1' || str === 'active' || str === 'enabled') {
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (str === '2' || str === 'inactive') {
return {
label: 'Inactive',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
}
if (str === '3' || str === 'disabled' || str === 'suspended' || str === 'terminated') {
return {
label: str === 'suspended' ? 'Suspended' : 'Disabled',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
}
if (str === 'approved') {
return {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (str === 'pending' || str === 'review') {
return {
label: 'Pending',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
};
}
const s = Number(status); const s = Number(status);
switch (s) { switch (s) {
case ActivityStatus.Active: case ActivityStatus.Active:
@@ -321,14 +366,44 @@ export const getActivityStatusBadge = (status: ActivityStatus | number) => {
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
}; };
case ActivityStatus.Disabled: case ActivityStatus.Disabled:
default:
return { return {
label: 'Disabled', label: 'Disabled',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
}; };
default:
return {
label: String(status),
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
};
} }
}; };
/**
* Robust Status Badge resolver for Organisation Directory.
* Inspects status, Status, activityStatus, approvalStatus, statusId, isActive, or state,
* parsing string, boolean, or numeric database representations.
*/
export const getOrganisationStatusBadge = (statusOrOrg: any) => {
let val: any = statusOrOrg;
if (statusOrOrg !== null && typeof statusOrOrg === 'object') {
val =
statusOrOrg.status ??
statusOrOrg.Status ??
statusOrOrg.activityStatus ??
statusOrOrg.ActivityStatus ??
statusOrOrg.approvalStatus ??
statusOrOrg.ApprovalStatus ??
statusOrOrg.statusId ??
statusOrOrg.StatusId ??
statusOrOrg.state ??
statusOrOrg.State ??
(statusOrOrg.isActive !== undefined ? (statusOrOrg.isActive ? 1 : 2) : undefined) ??
(statusOrOrg.IsActive !== undefined ? (statusOrOrg.IsActive ? 1 : 2) : undefined);
}
return getActivityStatusBadge(val);
};
export const getPriorityBadge = (priority: Priority | number) => { export const getPriorityBadge = (priority: Priority | number) => {
const p = Number(priority); const p = Number(priority);
switch (p) { switch (p) {
@@ -419,14 +494,17 @@ export const getNotificationStatusBadge = (status?: NotificationStatus | number
}; };
export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => { export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => {
const score = Number(likelihood) * Number(impact); const score = (Number(likelihood) || 1) * (Number(impact) || 1);
if (score >= 15) { if (score >= 15) {
return { level: 'High', color: 'bg-rose-600 text-white', badge: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' }; return { level: 'Critical', color: 'bg-rose-600 text-white', badge: 'bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold' };
} }
if (score >= 8) { if (score >= 10) {
return { level: 'Medium', color: 'bg-amber-500 text-white', badge: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' }; return { level: 'High', color: 'bg-orange-500 text-white', badge: 'bg-orange-500/20 text-orange-300 border border-orange-500/40 font-bold' };
} }
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' }; if (score >= 5) {
return { level: 'Moderate', color: 'bg-amber-500 text-white', badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold' };
}
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-semibold' };
}; };
// ========================================== // ==========================================
@@ -434,16 +512,61 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R
// ========================================== // ==========================================
const USER_EMAIL_CACHE: Record<string, string> = {}; const USER_EMAIL_CACHE: Record<string, string> = {};
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string, email: string) => { export const registerUserEmail = (userId: string | number, email: string) => {
if (userId && email) { if (userId && email && typeof email === 'string' && email.trim()) {
USER_EMAIL_CACHE[userId] = email; const cleanId = String(userId).trim();
const cleanEmail = email.trim();
USER_EMAIL_CACHE[cleanId] = cleanEmail;
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId;
} }
}; };
export const getUserEmail = (userIdOrEmail?: string | null): string => { export const registerUserEmails = (users: Array<{ id: string | number; email?: string | null }>) => {
if (!userIdOrEmail) return '—'; if (!Array.isArray(users)) return;
if (userIdOrEmail.includes('@')) return userIdOrEmail; for (const u of users) {
if (USER_EMAIL_CACHE[userIdOrEmail]) return USER_EMAIL_CACHE[userIdOrEmail]; if (u && u.id && u.email) {
return userIdOrEmail; registerUserEmail(u.id, u.email);
}
}
};
export const getUserIdByEmail = (email?: string | null): string | undefined => {
if (!email || typeof email !== 'string') return undefined;
return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()];
};
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
if (!userIdOrEmail) return '—';
const str = String(userIdOrEmail).trim();
if (!str) return '—';
if (str.includes('@')) return str;
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
// Synchronous fallback from stored auth session in browser
try {
const storedUserJson = localStorage.getItem('pfm_auth_user');
const storedSub = localStorage.getItem('pfm_user_sub');
if (storedUserJson) {
const parsed = JSON.parse(storedUserJson);
if (parsed?.email) {
const email = String(parsed.email).trim();
if (
(parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) ||
(parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) ||
(storedSub && String(storedSub).toLowerCase() === str.toLowerCase())
) {
registerUserEmail(str, email);
return email;
}
}
}
} catch {
// Ignore storage parse errors
}
return str;
}; };
+8 -3
View File
@@ -2,9 +2,14 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; import './index.css';
import { loadRuntimeConfig } from './config';
createRoot(document.getElementById('root')!).render( // Load runtime environment configuration before mounting the application
loadRuntimeConfig().finally(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
); );
});
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
TrendingUp, TrendingUp,
@@ -13,6 +13,7 @@ import {
RefreshCw, RefreshCw,
Clock, Clock,
ShieldAlert, ShieldAlert,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getDashboard, getDashboard,
@@ -23,16 +24,32 @@ import {
getBudgets, getBudgets,
getDisbursements, getDisbursements,
getAllRisks, getAllRisks,
getAllIndicators,
} from '../services/api'; } from '../services/api';
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters'; import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
import { ApprovalStatus } from '../types'; import { ApprovalStatus } from '../types';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { HelpTooltip } from '../components/ui/HelpTooltip';
export const ExecutiveDashboard: React.FC = () => { export const ExecutiveDashboard: React.FC = () => {
const queryClient = useQueryClient();
const [selectedProgramme, setSelectedProgramme] = useState('All'); const [selectedProgramme, setSelectedProgramme] = useState('All');
const [selectedSector, setSelectedSector] = useState('All'); const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27'); const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
// Real Queries from OpenAPI Endpoints const isFiltered = selectedProgramme !== 'All' || selectedSector !== 'All' || selectedPeriod !== 'FY 2026/27';
const activeFilterCount =
(selectedProgramme !== 'All' ? 1 : 0) +
(selectedSector !== 'All' ? 1 : 0) +
(selectedPeriod !== 'FY 2026/27' ? 1 : 0);
const resetFilters = () => {
setSelectedProgramme('All');
setSelectedSector('All');
setSelectedPeriod('FY 2026/27');
};
// Data queries
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({ const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: getDashboard, queryFn: getDashboard,
@@ -73,61 +90,140 @@ export const ExecutiveDashboard: React.FC = () => {
queryFn: () => getAllRisks(), queryFn: () => getAllRisks(),
}); });
const { data: indicators = [] } = useQuery({
queryKey: ['indicators'],
queryFn: () => getAllIndicators(),
});
const refetchAll = () => { const refetchAll = () => {
refetchDashboard(); refetchDashboard();
queryClient.invalidateQueries();
}; };
// Headline metrics sourced directly from live API models // Filtered projects according to active Programme and Sector dropdown selections
const filteredProjects = useMemo(() => {
return projects.filter((p) => {
if (selectedProgramme !== 'All' && String(p.programmeId) !== selectedProgramme) {
return false;
}
if (selectedSector !== 'All') {
const sectorLabel = getIndustrySectorLabel(p.sector);
if (sectorLabel !== selectedSector) return false;
}
return true;
});
}, [projects, selectedProgramme, selectedSector]);
// Filtered risks associated with the filtered project set
const filteredRisks = useMemo(() => {
if (selectedProgramme === 'All' && selectedSector === 'All') {
return risks;
}
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
return risks.filter((r) => projectIds.has(String(r.projectId)));
}, [risks, filteredProjects, selectedProgramme, selectedSector]);
// Headline portfolio metrics calculation
const portfolioValue = useMemo(() => { const portfolioValue = useMemo(() => {
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
const filteredBudgets = budgets.filter((b) => projectIds.has(String(b.projectId)));
const budgetTotal = filteredBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
if (budgetTotal > 0) return budgetTotal;
const filteredAwards = awards.filter((a) => projectIds.has(String(a.projectId)));
const awardsTotal = filteredAwards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return awardsTotal;
}
if (dashboard?.portfolioValue && dashboard.portfolioValue > 0) { if (dashboard?.portfolioValue && dashboard.portfolioValue > 0) {
return dashboard.portfolioValue; return dashboard.portfolioValue;
} }
const budgetTotal = budgets.reduce((sum, b) => sum + (b.amount || 0), 0); const budgetTotal = budgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
if (budgetTotal > 0) return budgetTotal; if (budgetTotal > 0) return budgetTotal;
const awardsTotal = awards.reduce((sum, a) => sum + (a.amount || 0), 0); const awardsTotal = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return awardsTotal; return awardsTotal;
}, [dashboard, budgets, awards]); }, [dashboard, budgets, awards, filteredProjects, selectedProgramme, selectedSector]);
const activeAwards = dashboard?.activeAwards ?? (awards.length || projects.length); const activeAwards = useMemo(() => {
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
const matched = awards.filter((a) => projectIds.has(String(a.projectId)));
return matched.length || filteredProjects.length;
}
if (awards.length > 0) return awards.length;
return dashboard?.activeAwards ?? projects.length;
}, [dashboard, awards, filteredProjects, projects, selectedProgramme, selectedSector]);
const disbursed = useMemo(() => { const disbursed = useMemo(() => {
if (dashboard?.disbursed && dashboard.disbursed > 0) { const relevantDisbursements = disbursements.filter((d) => {
return dashboard.disbursed; if (selectedProgramme !== 'All' || selectedSector !== 'All') {
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
return projectIds.has(String(d.projectId));
} }
return disbursements return true;
});
const approvedTotal = relevantDisbursements
.filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3) .filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3)
.reduce((sum, d) => sum + (d.amount || 0), 0); .reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
}, [dashboard, disbursements]);
if (approvedTotal > 0) return approvedTotal;
const allTotal = relevantDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
if (allTotal > 0 && (!dashboard?.disbursed || dashboard.disbursed === 0)) {
return allTotal;
}
return dashboard?.disbursed || 0;
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
const onTrackPercentage = useMemo(() => { const onTrackPercentage = useMemo(() => {
if (dashboard?.onTrackPercentage !== undefined && dashboard.onTrackPercentage > 0) { const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return 0;
const onTrack = targetProjects.filter(
(p) => p.status === ApprovalStatus.Approved || (p.status as number) === 3 || (p.status as unknown as string) === 'Active'
).length;
if (onTrack === 0 && dashboard?.onTrackPercentage && dashboard.onTrackPercentage > 0) {
return dashboard.onTrackPercentage; return dashboard.onTrackPercentage;
} }
if (projects.length === 0) return 0; return Math.round((onTrack / targetProjects.length) * 100);
const activeOnTrack = projects.filter((p) => p.status === ApprovalStatus.Approved).length; }, [dashboard, filteredProjects, projects]);
return Math.round((activeOnTrack / projects.length) * 100);
}, [dashboard, projects]);
// Calculate at-risk projects count based on risk scores
const atRiskCount = useMemo(() => { const atRiskCount = useMemo(() => {
if (dashboard?.atRiskCount !== undefined) { const targetRisks = filteredRisks;
return dashboard.atRiskCount; const highRisks = targetRisks.filter((r) => {
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
return score >= 12 || ((Number(r.impact) || 1) >= 4 && (Number(r.likelihood) || 1) >= 3);
});
if (targetRisks.length > 0) {
return highRisks.length;
} }
return risks.filter((r) => r.likelihood * r.impact >= 12).length; return dashboard?.atRiskCount ?? 0;
}, [dashboard, risks]); }, [dashboard, filteredRisks]);
const beneficiariesReached = dashboard?.beneficiariesReached ?? 0; const beneficiariesReached = useMemo(() => {
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
return dashboard.beneficiariesReached;
}
const sumActual = indicators.reduce((sum, ind) => sum + (Number(ind.actualAmount) || 0), 0);
return sumActual > 0 ? sumActual : 0;
}, [dashboard, indicators]);
// Real sector breakdown calculated dynamically from actual project records // Sector allocation breakdown
const sectorData = useMemo(() => { const sectorData = useMemo(() => {
if (!projects || projects.length === 0) return []; const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return [];
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
projects.forEach((p) => { targetProjects.forEach((p) => {
const label = getIndustrySectorLabel(p.sector); const label = getIndustrySectorLabel(p.sector);
counts[label] = (counts[label] || 0) + 1; counts[label] = (counts[label] || 0) + 1;
}); });
const colors = ['bg-emerald-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500', 'bg-purple-500', 'bg-amber-500']; const colors = ['bg-emerald-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500', 'bg-purple-500', 'bg-amber-500'];
const totalProjects = projects.length; const totalProjects = targetProjects.length;
return Object.entries(counts).map(([name, count], index) => { return Object.entries(counts).map(([name, count], index) => {
const percentage = Math.round((count / totalProjects) * 100); const percentage = Math.round((count / totalProjects) * 100);
@@ -140,29 +236,9 @@ export const ExecutiveDashboard: React.FC = () => {
color: colors[index % colors.length], color: colors[index % colors.length],
}; };
}); });
}, [projects, portfolioValue]); }, [projects, filteredProjects, portfolioValue]);
// Risk heatmap matrix computed directly from registered risk records // Quarterly disbursements distribution
const riskHeatmap = useMemo(() => {
const matrix = {
highImpHighLik: risks.filter((r) => r.impact >= 4 && r.likelihood >= 4).length,
highImpMedLik: risks.filter((r) => r.impact >= 4 && r.likelihood === 3).length,
highImpLowLik: risks.filter((r) => r.impact >= 4 && r.likelihood <= 2).length,
medImpHighLik: risks.filter((r) => r.impact === 3 && r.likelihood >= 4).length,
medImpMedLik: risks.filter((r) => r.impact === 3 && r.likelihood === 3).length,
medImpLowLik: risks.filter((r) => r.impact === 3 && r.likelihood <= 2).length,
lowImpHighLik: risks.filter((r) => r.impact <= 2 && r.likelihood >= 4).length,
lowImpMedLik: risks.filter((r) => r.impact <= 2 && r.likelihood === 3).length,
lowImpLowLik: risks.filter((r) => r.impact <= 2 && r.likelihood <= 2).length,
flaggedCount: risks.filter((r) => r.impact * r.likelihood >= 12).length,
};
return matrix;
}, [risks]);
// Quarterly disbursements distribution computed from real disbursement items
const quarterlyTrend = useMemo(() => { const quarterlyTrend = useMemo(() => {
if (!disbursements || disbursements.length === 0) { if (!disbursements || disbursements.length === 0) {
return [ return [
@@ -198,96 +274,196 @@ export const ExecutiveDashboard: React.FC = () => {
})); }));
}, [disbursements]); }, [disbursements]);
// Attention Projects derived directly from live project data // Projects requiring management attention based on risk profile
const attentionProjects = useMemo(() => { const attentionProjects = useMemo(() => {
if (!projects || projects.length === 0) return []; const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
return projects if (targetProjects.length === 0) return [];
.filter((p) => p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review)
.slice(0, 5) // Map each project to its highest residual risk score
const projectMaxRisk: Record<string, number> = {};
risks.forEach((r) => {
const pid = String(r.projectId);
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
if (!projectMaxRisk[pid] || score > projectMaxRisk[pid]) {
projectMaxRisk[pid] = score;
}
});
return targetProjects
.map((p) => { .map((p) => {
const maxScore = projectMaxRisk[String(p.id)] || 0;
const isRejected = p.status === ApprovalStatus.Rejected; const isRejected = p.status === ApprovalStatus.Rejected;
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review; const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
let riskLevel: 'RED' | 'AMBER' | 'GREEN' = 'GREEN';
let statusLabel = 'Active';
let variance = '0%';
let varianceType: 'negative' | 'positive' = 'positive';
if (isRejected || maxScore >= 15) {
riskLevel = 'RED';
statusLabel = 'At Risk';
variance = '-15%';
varianceType = 'negative';
} else if (isPending || maxScore >= 10) {
riskLevel = 'AMBER';
statusLabel = isPending ? 'Pending' : 'Review';
variance = '-5%';
varianceType = 'negative';
}
return { return {
id: p.id, id: p.id,
name: p.name, name: p.name,
programme: p.programmeName || `Programme ${p.programmeId}`, programme: p.programmeName || `Programme ${p.programmeId}`,
status: isRejected ? 'At Risk' : isPending ? 'Pending' : 'Active', status: statusLabel,
risk: isRejected ? 'RED' : isPending ? 'AMBER' : 'GREEN', risk: riskLevel,
financialVariance: isRejected ? '-15%' : '0%', financialVariance: variance,
varianceType: isRejected ? 'negative' : 'positive', varianceType,
maxScore,
}; };
}); })
}, [projects]); .filter((p) => p.risk === 'RED' || p.risk === 'AMBER')
.sort((a, b) => b.maxScore - a.maxScore)
.slice(0, 5);
}, [projects, filteredProjects, risks]);
return ( return (
<div className="space-y-6 max-w-7xl mx-auto"> <div className="space-y-6 max-w-7xl mx-auto">
{/* Page Title & Filter Bar */} {/* Page Title & Filter Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl"> <div className="bg-[#0d121f]/90 p-5 sm:p-6 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl space-y-4">
{/* Top Tier: Title, Status Badge, and Primary Actions */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-xl font-bold text-white tracking-tight"> <h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard Executive Portfolio Dashboard
</h2> </h2>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30"> <HelpTooltip
Live STS Feed title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Live Portfolio Feed
</span> </span>
</div> </div>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs sm:text-sm text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action. Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p> </p>
</div> </div>
{/* Filters */} <div className="flex items-center gap-2.5 shrink-0 self-start lg:self-center">
<div className="flex flex-wrap items-center gap-3"> {isFiltered && (
<div className="flex items-center gap-1.5 text-xs text-slate-400"> <button
<Filter className="w-3.5 h-3.5 text-slate-500" /> type="button"
<span>Filter:</span> onClick={resetFilters}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
title="Reset all filters to default"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset Filters</span>
</button>
)}
<button
type="button"
onClick={refetchAll}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div> </div>
{/* Dedicated Filter Toolbar */}
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>Filters:</span>
{activeFilterCount > 0 && (
<span className="text-[10px] font-bold text-blue-300 bg-blue-500/20 border border-blue-500/40 px-2 py-0.5 rounded-full">
{activeFilterCount} Active
</span>
)}
</div>
{/* Programme Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Programme
</span>
<select <select
value={selectedProgramme} value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)} onChange={(e) => setSelectedProgramme(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer" className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[200px] truncate"
> >
<option value="All">Programme: All</option> <option value="All">All</option>
{programmes.map((pr) => ( {programmes.map((pr) => (
<option key={pr.id} value={pr.id}> <option key={pr.id} value={pr.id}>
{pr.name} {pr.name}
</option> </option>
))} ))}
</select> </select>
<HelpTooltip
title="Programme Filter"
content="Isolates metrics and projects funded under a specific thematic programme mandate."
size="sm"
placement="bottom"
/>
</div>
{/* Sector Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Sector
</span>
<select <select
value={selectedSector} value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)} onChange={(e) => setSelectedSector(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer" className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[170px] truncate"
> >
<option value="All">Sector: All</option> <option value="All">All</option>
{sectorData.map((s) => ( {sectorData.map((s) => (
<option key={s.name} value={s.name}> <option key={s.name} value={s.name}>
{s.name} {s.name}
</option> </option>
))} ))}
</select> </select>
<HelpTooltip
title="Industry Sector Filter"
content="Filters analytics by designated economic industry sector (e.g. AgriTech, HealthTech, Renewable Energy)."
size="sm"
placement="bottom"
/>
</div>
{/* Financial Period Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Period
</span>
<select <select
value={selectedPeriod} value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)} onChange={(e) => setSelectedPeriod(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer" className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white"
> >
<option value="FY 2026/27">Period: FY 2026/27</option> <option value="FY 2026/27">FY 2026/27</option>
<option value="FY 2025/26">Period: FY 2025/26</option> <option value="FY 2025/26">FY 2025/26</option>
<option value="All-Time">All-Time Cumulative</option> <option value="All-Time">All-Time Cumulative</option>
</select> </select>
<HelpTooltip
<button title="Financial Period Filter"
onClick={refetchAll} content="Calculates disbursements and milestones according to the government fiscal year or cumulative life-of-programme."
disabled={isFetching} size="sm"
className="p-2 text-slate-400 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs" placement="bottom"
title="Refresh dashboard metrics" />
> </div>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin text-blue-400' : ''}`} />
</button>
</div> </div>
</div> </div>
@@ -296,7 +472,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 1: Portfolio Value */} {/* 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="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 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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
<HelpTooltip
title="Total Portfolio Value"
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
size="sm"
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"> <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" /> <Coins className="w-3.5 h-3.5" />
</div> </div>
@@ -310,7 +494,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 2: Active Awards */} {/* 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="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 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> <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"> <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" /> <Layers className="w-3.5 h-3.5" />
</div> </div>
@@ -326,7 +518,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 3: Disbursed */} {/* 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="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 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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
<HelpTooltip
title="Disbursed Funds"
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
size="sm"
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"> <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" /> <TrendingUp className="w-3.5 h-3.5" />
</div> </div>
@@ -342,7 +542,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 4: On Track */} {/* 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="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 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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
<HelpTooltip
title="On-Track Progress Ratio"
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
size="sm"
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"> <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" /> <CheckCircle className="w-3.5 h-3.5" />
</div> </div>
@@ -356,7 +564,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 5: At Risk */} {/* 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="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 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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">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."
size="sm"
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"> <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" /> <AlertTriangle className="w-3.5 h-3.5" />
</div> </div>
@@ -370,7 +586,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 6: Beneficiaries Reached */} {/* 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="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 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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
<HelpTooltip
title="Beneficiaries Impacted"
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
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"> <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" /> <Users className="w-3.5 h-3.5" />
</div> </div>
@@ -384,19 +608,27 @@ export const ExecutiveDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* Visual Models Section (Sector, Heatmap, Impact Trend) */} {/* Visual Models Section (Sector Breakdown & Disbursements) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Panel 1: Funding by Sector */} {/* Panel 1: Funding by Sector */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div> <div>
<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">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3> <h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
<HelpTooltip
title="Sector Funding Distribution"
content="Breakdown of active grant commitments distributed across designated industrial and socio-economic sectors."
size="sm"
placement="bottom"
/>
</div>
<span className="text-[11px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/30"> <span className="text-[11px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/30">
API Calculated Live Portfolio
</span> </span>
</div> </div>
{/* Circular Graphic Simulation */} {/* Sector Donut Chart */}
<div className="py-6 flex items-center justify-center"> <div className="py-6 flex items-center justify-center">
<div className="relative w-36 h-36 rounded-full border-8 border-slate-800 flex items-center justify-center"> <div className="relative w-36 h-36 rounded-full border-8 border-slate-800 flex items-center justify-center">
<div className="w-24 h-24 rounded-full border-4 border-blue-500 flex flex-col items-center justify-center bg-[#090d16] shadow-inner"> <div className="w-24 h-24 rounded-full border-4 border-blue-500 flex flex-col items-center justify-center bg-[#090d16] shadow-inner">
@@ -430,85 +662,25 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="pt-4 border-t border-slate-800/80 mt-4 text-[11px] text-slate-400 flex justify-between"> <div className="pt-4 border-t border-slate-800/80 mt-4 text-[11px] text-slate-400 flex justify-between">
<span>Primary Focus: {sectorData[0]?.name || 'All Sectors'}</span> <span>Primary Focus: {sectorData[0]?.name || 'All Sectors'}</span>
<Link to="/governance" className="text-blue-400 font-medium hover:underline flex items-center gap-1"> <Link to="/governance?tab=portfolios" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
View Portfolios <ArrowUpRight className="w-3 h-3" /> View Portfolios <ArrowUpRight className="w-3 h-3" />
</Link> </Link>
</div> </div>
</div> </div>
{/* Panel 2: Portfolio Risk Heatmap */} {/* Panel 2: Disbursement Distribution */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Portfolio Risk Heatmap</h3>
<span className="text-[11px] font-mono text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-semibold">
{riskHeatmap.flaggedCount} Flagged
</span>
</div>
<p className="text-[11px] text-slate-400 my-2">
Matrix distribution of project risks by Likelihood (1–5) and Impact (1–5).
</p>
{/* 3x3 Heatmap Matrix with real counted risks */}
<div className="grid grid-cols-3 gap-2 my-4">
{/* Row 1 - High Impact */}
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.highImpLowLik} {riskHeatmap.highImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-950/50 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/60">
<span className="text-[10px] block text-rose-400 font-normal">High</span>
{riskHeatmap.highImpMedLik} {riskHeatmap.highImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-900/80 text-rose-100 p-2.5 rounded-xl text-center text-xs font-bold border border-rose-500 shadow-md">
<span className="text-[10px] block text-rose-300 font-normal">Critical</span>
{riskHeatmap.highImpHighLik} {riskHeatmap.highImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
{/* Row 2 - Medium Impact */}
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
{riskHeatmap.medImpLowLik} {riskHeatmap.medImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.medImpMedLik} {riskHeatmap.medImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-950/40 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/50">
<span className="text-[10px] block text-rose-400 font-normal">High</span>
{riskHeatmap.medImpHighLik} {riskHeatmap.medImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
{/* Row 3 - Low Impact */}
<div className="bg-slate-900/80 text-slate-300 p-2.5 rounded-xl text-center text-xs font-semibold border border-slate-800">
<span className="text-[10px] block text-slate-500 font-normal">Trivial</span>
{riskHeatmap.lowImpLowLik} {riskHeatmap.lowImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
{riskHeatmap.lowImpMedLik} {riskHeatmap.lowImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.lowImpHighLik} {riskHeatmap.lowImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-800/80 text-[11px] text-slate-400 flex justify-between items-center">
<span>Total Registered Risks: {risks.length}</span>
<Link to="/monitoring" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
Risk Register <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Panel 3: Funding-to-Impact Trend */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm"> <div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div> <div>
<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">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Disbursement Distribution</h3> <h3 className="text-sm font-semibold text-white">Disbursement Distribution</h3>
<HelpTooltip
title="Disbursement Timeline & Cadence"
content="Compares statutory multi-year grant tranche commitments against verified payouts released across calendar quarters."
size="sm"
placement="bottom"
/>
</div>
<span className="text-[11px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30 font-semibold"> <span className="text-[11px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30 font-semibold">
{disbursements.length} Tranche{disbursements.length === 1 ? '' : 's'} {disbursements.length} Tranche{disbursements.length === 1 ? '' : 's'}
</span> </span>
@@ -518,7 +690,7 @@ export const ExecutiveDashboard: React.FC = () => {
Quarterly release distribution across active portfolios and milestone verifications. Quarterly release distribution across active portfolios and milestone verifications.
</p> </p>
{/* Graphic Trend Visualization from Live Disbursements */} {/* Quarterly Disbursement Trend Chart */}
<div className="h-44 flex items-end gap-3 pt-6 pb-2 px-2 border-b border-slate-800/80"> <div className="h-44 flex items-end gap-3 pt-6 pb-2 px-2 border-b border-slate-800/80">
{quarterlyTrend.map((q) => ( {quarterlyTrend.map((q) => (
<div key={q.quarter} className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end"> <div key={q.quarter} className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end">
@@ -558,11 +730,27 @@ export const ExecutiveDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* Panel 3: Portfolio Risk Heatmap & Matrix */}
<RiskHeatmapMatrix
risks={filteredRisks}
projects={projects}
title="Portfolio Risk Heatmap & Matrix"
subtitle="Aggregated ISO 31000 distribution of project risks by Likelihood (1–5) and Impact (1–5)"
/>
{/* Projects Requiring Attention Table */} {/* Projects Requiring Attention Table */}
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-lg backdrop-blur-sm"> <div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-lg backdrop-blur-sm">
<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 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>
<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 Requiring Attention</h3>
<HelpTooltip
title="Exception Escalations"
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Prioritized by risk severity, delivery delay, or financial variance threshold breach. Prioritized by risk severity, delivery delay, or financial variance threshold breach.
</p> </p>
@@ -604,7 +792,8 @@ export const ExecutiveDashboard: React.FC = () => {
<td className="py-3.5 px-4 text-slate-300 font-medium">{p.programme}</td> <td className="py-3.5 px-4 text-slate-300 font-medium">{p.programme}</td>
<td className="py-3.5 px-4"> <td className="py-3.5 px-4">
<span <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${p.status === 'At Risk' className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${
p.status === 'At Risk'
? 'bg-rose-500/10 text-rose-400 border-rose-500/30' ? 'bg-rose-500/10 text-rose-400 border-rose-500/30'
: p.status === 'Pending' : p.status === 'Pending'
? 'bg-amber-500/10 text-amber-400 border-amber-500/30' ? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
@@ -616,7 +805,8 @@ export const ExecutiveDashboard: React.FC = () => {
</td> </td>
<td className="py-3.5 px-4 text-center"> <td className="py-3.5 px-4 text-center">
<span <span
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${p.risk === 'RED' className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
p.risk === 'RED'
? 'bg-rose-600 text-white' ? 'bg-rose-600 text-white'
: p.risk === 'AMBER' : p.risk === 'AMBER'
? 'bg-amber-500 text-white' ? 'bg-amber-500 text-white'
@@ -628,7 +818,8 @@ export const ExecutiveDashboard: React.FC = () => {
</td> </td>
<td className="py-3.5 px-4 text-right"> <td className="py-3.5 px-4 text-right">
<span <span
className={`font-mono font-bold text-xs ${p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400' className={`font-mono font-bold text-xs ${
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
}`} }`}
> >
{p.financialVariance} {p.financialVariance}
File diff suppressed because it is too large Load Diff
@@ -18,17 +18,29 @@ import {
ExternalLink, ExternalLink,
DollarSign, DollarSign,
FileCheck2, FileCheck2,
Download,
Upload,
Link as LinkIcon,
Edit2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAwards, getAwards,
getAward,
createAward, createAward,
getContracts, getContracts,
getContract,
createContract, createContract,
updateContract,
uploadContractFile,
getContractSecureLink,
executeContract, executeContract,
getBudgets, getBudgets,
getBudget,
createBudget, createBudget,
getDisbursements, getDisbursements,
getDisbursement,
createDisbursement, createDisbursement,
updateDisbursement,
approveDisbursement, approveDisbursement,
getOrganisations, getOrganisations,
getAllProgrammes, getAllProgrammes,
@@ -36,6 +48,7 @@ import {
getAllMilestones, getAllMilestones,
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { import {
Award, Award,
@@ -59,13 +72,13 @@ import {
getUserEmail, getUserEmail,
} from '../lib/formatters'; } from '../lib/formatters';
// Schemas matching Figure 9 and OpenAPI constraints // Validation schemas for grant administration
const awardSchema = z.object({ const awardSchema = z.object({
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory (FR-M01-002)'), referenceNo: z.string().min(4, 'Approval / Reference number is mandatory'),
amount: z.coerce.number().min(1, 'Approved funding amount must be > 0 (FR-M01-003)'), amount: z.coerce.number().min(1, 'Approved funding amount must be greater than zero'),
fundingPeriod: z.string().min(3, 'Funding period is mandatory (e.g. 24 Months)'), fundingPeriod: z.string().min(3, 'Funding period is mandatory (e.g. 24 Months)'),
organisationId: z.coerce.number().min(1, 'Select recipient organisation (FR-M01-006)'), organisationId: z.coerce.number().min(1, 'Select recipient organisation'),
programmeId: z.coerce.number().min(1, 'Select funding programme (FR-M01-005)'), programmeId: z.coerce.number().min(1, 'Select funding programme'),
}); });
const contractSchema = z.object({ const contractSchema = z.object({
@@ -112,6 +125,7 @@ export const GrantsPage: React.FC = () => {
const [selectedContractForExecution, setSelectedContractForExecution] = useState<Contract | null>(null); const [selectedContractForExecution, setSelectedContractForExecution] = useState<Contract | null>(null);
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false); const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false); const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
// Queries // Queries
const { data: awards = [], isLoading: loadingAwards } = useQuery({ const { data: awards = [], isLoading: loadingAwards } = useQuery({
@@ -217,7 +231,7 @@ export const GrantsPage: React.FC = () => {
awardForm.reset(); awardForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Award registration failed', String(err)); toastError('Award registration failed', err);
}, },
}); });
@@ -230,7 +244,7 @@ export const GrantsPage: React.FC = () => {
contractForm.reset(); contractForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Contract creation failed', String(err)); toastError('Contract creation failed', err);
}, },
}); });
@@ -240,13 +254,13 @@ export const GrantsPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] }); queryClient.invalidateQueries({ queryKey: ['contracts'] });
success( success(
'Contract Executed & Locked', 'Contract Executed & Locked',
'Agreement locked as authoritative version (AT-02). Downstream budgets and disbursements enabled.' 'Agreement locked as authoritative version. Downstream budgets and disbursements enabled.'
); );
setIsExecuteModalOpen(false); setIsExecuteModalOpen(false);
executeForm.reset(); executeForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Execution trigger failed', String(err)); toastError('Execution trigger failed', err);
}, },
}); });
@@ -259,23 +273,60 @@ export const GrantsPage: React.FC = () => {
budgetForm.reset(); budgetForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Budget creation failed', String(err)); toastError('Budget creation failed', err);
}, },
}); });
const disbursementMutation = useMutation({ const disbursementMutation = useMutation({
mutationFn: (data: CreateDisbursementRequest) => createDisbursement(data), mutationFn: async (data: DisbursementFormValues) => {
if (editingDisbursement) {
return updateDisbursement({
disbursementId: editingDisbursement.id,
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
amount: Number(data.amount),
});
}
return createDisbursement({
projectId: Number(data.projectId),
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
amount: Number(data.amount),
});
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['disbursements'] }); queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success('Disbursement Scheduled', 'Payment tranche added in Pending Review state'); success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
editingDisbursement ? 'Payment tranche details updated' : 'Payment tranche added in Pending Review state'
);
setIsDisbursementModalOpen(false); setIsDisbursementModalOpen(false);
setEditingDisbursement(null);
disbursementForm.reset(); disbursementForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Disbursement creation failed', String(err)); toastError(editingDisbursement ? 'Disbursement update failed' : 'Disbursement creation failed', err);
}, },
}); });
const openCreateDisbursement = () => {
setEditingDisbursement(null);
disbursementForm.reset({
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
milestoneId: undefined,
amount: 150000,
});
setIsDisbursementModalOpen(true);
};
const openEditDisbursement = (d: Disbursement) => {
setEditingDisbursement(d);
disbursementForm.reset({
projectId: Number(d.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
milestoneId: d.milestoneId ? Number(d.milestoneId) : undefined,
amount: d.amount,
});
setIsDisbursementModalOpen(true);
};
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id), mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => { onSuccess: () => {
@@ -283,11 +334,11 @@ export const GrantsPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success( success(
'Payment Approved & Released', 'Payment Approved & Released',
'Conditions precedent verified. EFT reference generated and disbursement ledger updated (AT-05).' 'Conditions precedent verified. EFT reference generated and disbursement ledger updated.'
); );
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Disbursement approval failed', String(err)); toastError('Disbursement approval failed', err);
}, },
}); });
@@ -301,6 +352,46 @@ export const GrantsPage: React.FC = () => {
setIsExecuteModalOpen(true); setIsExecuteModalOpen(true);
}; };
const handleGetContractSecureLink = async (contractId: string | number) => {
try {
const url = await getContractSecureLink(contractId);
if (url && typeof url === 'string' && url.trim() !== '') {
const link = document.createElement('a');
link.href = url.trim();
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
success('Secure URL Retrieved', 'Temporary signed download link opened.');
} else {
toastError('Download Error', 'No active secure download link generated.');
}
} catch (err: any) {
toastError('Link Error', err);
}
};
const handleContractFileUpload = async (contractId: string | number, file: File) => {
try {
await uploadContractFile(contractId, file);
success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
queryClient.invalidateQueries({ queryKey: ['contracts'] });
} catch (err: any) {
toastError('Upload Failed', err);
}
};
const handleUpdateContractStatus = async (contractId: string | number, status: ContractStatus) => {
try {
await updateContract({ contractId, status });
success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
queryClient.invalidateQueries({ queryKey: ['contracts'] });
} catch (err: any) {
toastError('Update Failed', err);
}
};
// Summary calculations // Summary calculations
const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const totalDisbursedValue = disbursements const totalDisbursedValue = disbursements
@@ -316,9 +407,17 @@ export const GrantsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5"> <div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-white tracking-tight">
Grant & Financial Management Grant & Financial Management
</h2> </h2>
<HelpTooltip
title="Grant Financial Lifecycle"
content="Enforces the statutory financial control thread: Awards (M01) -> Contract Execution & Vault (M02) -> Multi-Year Budgets (M03) -> Condition-Checked Tranche Disbursements (M04)."
size="md"
placement="right"
/>
</div>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
End-to-end controlled execution thread: Funding Award Intake &rarr; Contracting &rarr; Budgeting &rarr; Condition-Checked Disbursement. End-to-end controlled execution thread: Funding Award Intake &rarr; Contracting &rarr; Budgeting &rarr; Condition-Checked Disbursement.
</p> </p>
@@ -354,7 +453,7 @@ export const GrantsPage: React.FC = () => {
)} )}
{activeTab === 'disbursements' && ( {activeTab === 'disbursements' && (
<button <button
onClick={() => setIsDisbursementModalOpen(true)} onClick={openCreateDisbursement}
className="inline-flex items-center gap-1.5 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" className="inline-flex items-center gap-1.5 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"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -367,15 +466,39 @@ export const GrantsPage: React.FC = () => {
{/* Pipeline Summary Bar */} {/* Pipeline Summary Bar */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-800/80 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-800/80 text-xs">
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between"> <div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-slate-400 font-medium">Total Awards Registered</span> <span className="text-slate-400 font-medium">Total Awards Registered</span>
<HelpTooltip
title="Total Awards"
content="Cumulative grant allocation amounts committed across all approved beneficiary entities."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-white font-mono">{formatCompactZAR(totalAwardValue)}</span> <span className="font-bold text-white font-mono">{formatCompactZAR(totalAwardValue)}</span>
</div> </div>
<div className="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between"> <div className="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span> <span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span>
<HelpTooltip
title="Disbursed Capital"
content="Funds verified, authorized by accounting officer, and disbursed into beneficiary bank accounts."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-emerald-300 font-mono">{formatCompactZAR(totalDisbursedValue)}</span> <span className="font-bold text-emerald-300 font-mono">{formatCompactZAR(totalDisbursedValue)}</span>
</div> </div>
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between"> <div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-amber-400 font-medium">Pending Release</span> <span className="text-amber-400 font-medium">Pending Release</span>
<HelpTooltip
title="Pending Release"
content="Tranches currently pending condition precedent verification, milestone sign-off, or statutory review."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span> <span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span>
</div> </div>
</div> </div>
@@ -385,7 +508,8 @@ export const GrantsPage: React.FC = () => {
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto"> <div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button <button
onClick={() => setActiveTab('awards')} onClick={() => setActiveTab('awards')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'awards' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
activeTab === 'awards'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
@@ -395,7 +519,8 @@ export const GrantsPage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('contracts')} onClick={() => setActiveTab('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'contracts' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
activeTab === 'contracts'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
@@ -405,7 +530,8 @@ export const GrantsPage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('budgets')} onClick={() => setActiveTab('budgets')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'budgets' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
activeTab === 'budgets'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
@@ -415,7 +541,8 @@ export const GrantsPage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('disbursements')} onClick={() => setActiveTab('disbursements')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'disbursements' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
activeTab === 'disbursements'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
@@ -527,20 +654,59 @@ export const GrantsPage: React.FC = () => {
</span> </span>
</td> </td>
<td className="py-3.5 px-5 text-right"> <td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => handleGetContractSecureLink(c.id)}
title="Fetch Secure Download URL"
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
<label
title="Upload Executed Contract Document"
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer inline-flex items-center"
>
<Upload className="w-3.5 h-3.5" />
<input
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleContractFileUpload(c.id, file);
}}
/>
</label>
<select
value={c.status}
onChange={(e) => handleUpdateContractStatus(c.id, Number(e.target.value) as ContractStatus)}
className="text-[10px] bg-slate-900/80 border border-slate-700/80 rounded-lg px-2 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
title="Update Status"
>
<option value={ContractStatus.Draft}>Draft</option>
<option value={ContractStatus.PendingApproval}>Pending Approval</option>
<option value={ContractStatus.Active}>Active / Executed</option>
<option value={ContractStatus.Terminated}>Terminated</option>
</select>
{isExecuted ? ( {isExecuted ? (
<span className="inline-flex items-center gap-1.5 text-[11px] text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20"> <span className="inline-flex items-center gap-1 text-[11px] text-emerald-400 font-semibold bg-emerald-500/10 px-2 py-1 rounded-lg border border-emerald-500/20">
<Lock className="w-3.5 h-3.5" /> <Lock className="w-3 h-3" />
Locked Locked
</span> </span>
) : ( ) : (
<button <button
type="button"
onClick={() => openExecuteModal(c)} onClick={() => openExecuteModal(c)}
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-lg shadow-md shadow-blue-500/20 transition" className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-xs shadow-blue-500/20 transition cursor-pointer"
> >
<FileCheck2 className="w-3.5 h-3.5" /> <FileCheck2 className="w-3 h-3" />
Execute Contract Execute
</button> </button>
)} )}
</div>
</td> </td>
</tr> </tr>
); );
@@ -648,21 +814,33 @@ export const GrantsPage: React.FC = () => {
</span> </span>
</td> </td>
<td className="py-3.5 px-5 text-right"> <td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
{isApproved ? ( {isApproved ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20"> <span className="inline-flex items-center gap-1 text-xs text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
Released Released
</span> </span>
) : ( ) : (
<>
<button
type="button"
onClick={() => openEditDisbursement(d)}
title="Edit Disbursement"
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 transition cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button <button
onClick={() => approveMutation.mutate(d.id)} onClick={() => approveMutation.mutate(d.id)}
disabled={approveMutation.isPending} disabled={approveMutation.isPending}
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-lg shadow-md shadow-blue-500/20 transition" 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-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
> >
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
Approve Payment Approve Payment
</button> </button>
</>
)} )}
</div>
</td> </td>
</tr> </tr>
); );
@@ -680,13 +858,22 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsAwardModalOpen(false)} onClose={() => setIsAwardModalOpen(false)}
title="Register Approved Funding Award" title="Register Approved Funding Award"
subtitle="Step 1 of 3 — Award Details" subtitle="Step 1 of 3 — Award Details"
helpText="Register an approved grant allocation. Once registered, the system will automatically prepare a draft contract workflow linked to this award."
> >
<form onSubmit={awardForm.handleSubmit((data) => awardMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={awardForm.handleSubmit((data) => awardMutation.mutate(data as any))} className="space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approval / Reference Number * Approval / Reference Number *
</label> </label>
<HelpTooltip
title="Statutory Reference Number"
content="Formal committee approval voucher or gazetted award identifier."
size="sm"
placement="top"
/>
</div>
<input <input
type="text" type="text"
{...awardForm.register('referenceNo')} {...awardForm.register('referenceNo')}
@@ -699,9 +886,17 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Funding Amount (ZAR) * Approved Funding Amount (ZAR) *
</label> </label>
<HelpTooltip
title="Award Quantum"
content="Total approved allocation authorized by the statutory grants board."
size="sm"
placement="top"
/>
</div>
<input <input
type="number" type="number"
{...awardForm.register('amount')} {...awardForm.register('amount')}
@@ -715,9 +910,17 @@ export const GrantsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
Recipient Organisation [search] * <label className="block text-xs font-semibold text-slate-300">
Recipient Organisation *
</label> </label>
<HelpTooltip
title="Beneficiary Entity"
content="Verified enterprise, university, or consortium holding the funding agreement."
size="sm"
placement="top"
/>
</div>
<select <select
{...awardForm.register('organisationId')} {...awardForm.register('organisationId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -731,9 +934,17 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Funding Programme / Instrument * Funding Programme / Instrument *
</label> </label>
<HelpTooltip
title="Statutory Fund Instrument"
content="The national budget vote or sub-programme financing this grant award."
size="sm"
placement="top"
/>
</div>
<select <select
{...awardForm.register('programmeId')} {...awardForm.register('programmeId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -748,7 +959,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Funding Period *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Funding Period *</label>
<HelpTooltip
title="Funding Duration"
content="Tenure of the grant agreement (e.g., 24 Months, 36 Months)."
size="sm"
placement="right"
/>
</div>
<input <input
type="text" type="text"
{...awardForm.register('fundingPeriod')} {...awardForm.register('fundingPeriod')}
@@ -787,10 +1006,19 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsContractModalOpen(false)} onClose={() => setIsContractModalOpen(false)}
title="Draft Contract Agreement" title="Draft Contract Agreement"
subtitle="Convert approved funding decision into legally enforceable terms" subtitle="Convert approved funding decision into legally enforceable terms"
helpText="Draft a legally binding grant agreement. Once drafted, contracts can be executed with digital signature verification to lock the terms."
> >
<form onSubmit={contractForm.handleSubmit((data) => contractMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={contractForm.handleSubmit((data) => contractMutation.mutate(data as any))} className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Award *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Select Award *</label>
<HelpTooltip
title="Underlying Award"
content="The approved statutory funding decision forming the legal premise for this contract."
size="sm"
placement="right"
/>
</div>
<select <select
{...contractForm.register('awardId')} {...contractForm.register('awardId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -804,7 +1032,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Contract Total Value (ZAR) *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Contract Total Value (ZAR) *</label>
<HelpTooltip
title="Total Contract Value"
content="Maximum statutory financial liability established under this contract agreement."
size="sm"
placement="right"
/>
</div>
<input <input
type="number" type="number"
{...contractForm.register('totalValue')} {...contractForm.register('totalValue')}
@@ -814,7 +1050,15 @@ export const GrantsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Effective Date *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Effective Date *</label>
<HelpTooltip
title="Effective Date"
content="The legal commencement date from which project activities become eligible for grant reimbursement."
size="sm"
placement="top"
/>
</div>
<input <input
type="date" type="date"
{...contractForm.register('effectiveAt')} {...contractForm.register('effectiveAt')}
@@ -822,7 +1066,15 @@ export const GrantsPage: React.FC = () => {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Expiry Date *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Expiry Date *</label>
<HelpTooltip
title="Expiry Date"
content="Formal termination date after which unexpended commitments lapse."
size="sm"
placement="top"
/>
</div>
<input <input
type="date" type="date"
{...contractForm.register('expiresAt')} {...contractForm.register('expiresAt')}
@@ -856,6 +1108,7 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsExecuteModalOpen(false)} onClose={() => setIsExecuteModalOpen(false)}
title="Execute Contract & Lock Agreement" title="Execute Contract & Lock Agreement"
subtitle={`Locking Contract #${selectedContractForExecution?.id} as Authoritative Version`} subtitle={`Locking Contract #${selectedContractForExecution?.id} as Authoritative Version`}
helpText="Electronic execution permanently locks the agreement. After execution, the contract cannot be modified, and milestone-linked disbursements can proceed."
> >
<form onSubmit={executeForm.handleSubmit((data) => executeMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={executeForm.handleSubmit((data) => executeMutation.mutate(data as any))} className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs space-y-1 text-slate-300"> <div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs space-y-1 text-slate-300">
@@ -866,9 +1119,17 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
External Electronic Signature ID * External Electronic Signature ID *
</label> </label>
<HelpTooltip
title="Electronic Signature Hash"
content="Unique audit certificate or transaction ID from the signing authority (e.g. DocuSign, Adobe Sign, or government PKI)."
size="sm"
placement="right"
/>
</div>
<input <input
type="text" type="text"
{...executeForm.register('externalSignatureId')} {...executeForm.register('externalSignatureId')}
@@ -878,7 +1139,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Signed Document URL / Vault *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Signed Document URL / Vault *</label>
<HelpTooltip
title="Authoritative Document Vault"
content="Direct secure URL or object storage path to the countersigned PDF agreement."
size="sm"
placement="right"
/>
</div>
<input <input
type="url" type="url"
{...executeForm.register('signedDocumentUrl')} {...executeForm.register('signedDocumentUrl')}
@@ -911,10 +1180,19 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsBudgetModalOpen(false)} onClose={() => setIsBudgetModalOpen(false)}
title="Add Project Budget Line" title="Add Project Budget Line"
subtitle="Maintain approved expenditure breakdown and commitments" subtitle="Maintain approved expenditure breakdown and commitments"
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
> >
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Funded Project"
content="Target project entity against which expenditure is tracked."
size="sm"
placement="right"
/>
</div>
<select <select
{...budgetForm.register('projectId')} {...budgetForm.register('projectId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -928,7 +1206,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Budget Category Name *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Budget Category Name *</label>
<HelpTooltip
title="Category Name"
content="Standard chart of accounts line (e.g. Personnel, Research Consumables, Capital Equipment)."
size="sm"
placement="right"
/>
</div>
<input <input
type="text" type="text"
{...budgetForm.register('name')} {...budgetForm.register('name')}
@@ -938,7 +1224,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Approved Line Amount (ZAR) *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Approved Line Amount (ZAR) *</label>
<HelpTooltip
title="Line Allocation"
content="Ceiling limit allocated for this specific cost category."
size="sm"
placement="right"
/>
</div>
<input <input
type="number" type="number"
{...budgetForm.register('amount')} {...budgetForm.register('amount')}
@@ -965,16 +1259,33 @@ export const GrantsPage: React.FC = () => {
</form> </form>
</Modal> </Modal>
{/* Modal: Schedule Disbursement */} {/* Modal: Schedule or Edit Disbursement */}
<Modal <Modal
isOpen={isDisbursementModalOpen} isOpen={isDisbursementModalOpen}
onClose={() => setIsDisbursementModalOpen(false)} onClose={() => {
title="Schedule Disbursement Tranche" setIsDisbursementModalOpen(false);
subtitle="Link disbursement to milestone verification and conditions precedent" setEditingDisbursement(null);
}}
title={editingDisbursement ? 'Edit Disbursement Tranche' : 'Schedule Disbursement Tranche'}
subtitle={
editingDisbursement
? `Updating payment tranche DSB-${editingDisbursement.id}`
: 'Link disbursement to milestone verification and conditions precedent'
}
helpText="Create or adjust scheduled disbursement tranches. Payment release requires verified milestone completion and positive compliance checks."
> >
<form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4">
{!editingDisbursement && (
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Select Project *</label>
<HelpTooltip
title="Target Project"
content="The project entity receiving this payment tranche."
size="sm"
placement="right"
/>
</div>
<select <select
{...disbursementForm.register('projectId')} {...disbursementForm.register('projectId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -986,9 +1297,18 @@ export const GrantsPage: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
)}
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Linked Milestone Deliverable</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Linked Milestone Deliverable</label>
<HelpTooltip
title="Milestone Requirement"
content="If linked, the disbursement is held until the milestone is marked Approved/Verified."
size="sm"
placement="right"
/>
</div>
<select <select
{...disbursementForm.register('milestoneId')} {...disbursementForm.register('milestoneId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -1003,7 +1323,15 @@ export const GrantsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Tranche Amount (ZAR) *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Tranche Amount (ZAR) *</label>
<HelpTooltip
title="Tranche Quantum"
content="Exact currency value to be transferred upon authorization."
size="sm"
placement="right"
/>
</div>
<input <input
type="number" type="number"
{...disbursementForm.register('amount')} {...disbursementForm.register('amount')}
@@ -1014,7 +1342,10 @@ export const GrantsPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2"> <div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => setIsDisbursementModalOpen(false)} onClick={() => {
setIsDisbursementModalOpen(false);
setEditingDisbursement(null);
}}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition" className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
> >
Cancel Cancel
@@ -1024,7 +1355,11 @@ export const GrantsPage: React.FC = () => {
disabled={disbursementMutation.isPending} disabled={disbursementMutation.isPending}
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" 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"
> >
{disbursementMutation.isPending ? 'Scheduling...' : 'Schedule Disbursement'} {disbursementMutation.isPending
? 'Saving...'
: editingDisbursement
? 'Update Disbursement'
: 'Schedule Disbursement'}
</button> </button>
</div> </div>
</form> </form>
@@ -4,7 +4,6 @@ import {
ShieldCheck, ShieldCheck,
Lock, Lock,
ArrowRight, ArrowRight,
Layers,
CheckCircle2, CheckCircle2,
Coins, Coins,
FileSpreadsheet, FileSpreadsheet,
@@ -16,9 +15,10 @@ import {
ChevronRight, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../auth/AuthProvider'; import { useAuth } from '../auth/AuthProvider';
import { BrandLogo } from '../components/ui/BrandLogo';
export const LandingPage: React.FC = () => { export const LandingPage: React.FC = () => {
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth(); const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const handleEnterConsole = async () => { const handleEnterConsole = async () => {
@@ -53,14 +53,12 @@ export const LandingPage: React.FC = () => {
{/* Top Header */} {/* Top Header */}
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20"> <header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20"> <BrandLogo size={40} showGlow animateOnHover />
<Layers className="w-5 h-5 text-white" />
</div>
<div> <div>
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2"> <h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
PFM Console PFM Console
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono"> <span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
Enterprise STS Enterprise SSO
</span> </span>
</h1> </h1>
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p> <p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
@@ -72,15 +70,19 @@ export const LandingPage: React.FC = () => {
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
id="header-nav-console-btn" id="header-nav-console-btn"
className="flex items-center gap-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 px-3.5 py-1.5 rounded-lg shadow-md shadow-blue-600/20 transition cursor-pointer" className={`flex items-center gap-2 text-xs font-semibold text-white px-3.5 py-1.5 rounded-lg shadow-md transition cursor-pointer ${
isPendingActivation
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-600/20'
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20'
}`}
> >
<span>Go to Console</span> <span>{isPendingActivation ? 'Check Activation Status' : 'Go to Console'}</span>
<ArrowRight className="w-3.5 h-3.5" /> <ArrowRight className="w-3.5 h-3.5" />
</button> </button>
) : ( ) : (
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full"> <div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span> <span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Duende STS Ready</span> <span>Single Sign-On Ready</span>
</div> </div>
)} )}
</div> </div>
@@ -159,7 +161,7 @@ export const LandingPage: React.FC = () => {
</span> </span>
</div> </div>
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded"> <span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
STS Token Active Session Active
</span> </span>
</div> </div>
@@ -181,22 +183,48 @@ export const LandingPage: React.FC = () => {
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span> <span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
</div> </div>
)} )}
{((claims.tenant as string) || user?.tenant) && (
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
<span>Assigned Tenant:</span>
<span className="font-semibold text-blue-300 truncate max-w-[200px]">
{(claims.tenant as string) || user?.tenant}
</span>
</div>
)}
</div> </div>
{/* Guidance Message */} {/* Guidance Message */}
{isPendingActivation ? (
<div className="p-4 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 leading-relaxed space-y-1">
<p className="font-semibold text-white flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-400 animate-pulse"></span>
Account Pending Administrator Activation
</p>
<p>
You are authenticated, but your account has not yet been enabled in the directory by an administrator.
Click below to enter the console and check your activation status.
</p>
</div>
) : (
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed"> <div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
<p className="font-semibold text-white mb-1">Session Verified</p> <p className="font-semibold text-white mb-1">Session Verified</p>
You are authenticated through Duende Identity Server. Your token claims are validated and active. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out through the identity server. You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session.
</div> </div>
)}
{/* Navigation and Sign Out Buttons */} {/* Navigation and Sign Out Buttons */}
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-1">
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
id="btn-return-to-console" id="btn-return-to-console"
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer" className={`w-full py-3.5 px-5 rounded-xl font-semibold text-sm text-white shadow-lg flex items-center justify-center gap-2 transition cursor-pointer ${
isPendingActivation
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-600/25'
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/25'
}`}
> >
<span>Return to PFM Console</span> <span>{isPendingActivation ? 'Enter Console & Sync Status' : 'Return to PFM Console'}</span>
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</button> </button>
@@ -206,16 +234,19 @@ export const LandingPage: React.FC = () => {
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer" className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
> >
<LogOut className="w-3.5 h-3.5" /> <LogOut className="w-3.5 h-3.5" />
<span>Sign Out via Identity Server</span> <span>Sign Out of Session</span>
</button> </button>
</div> </div>
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between"> <div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> <KeyRound className="w-3.5 h-3.5 text-blue-400" />
Duende STS <span>OAuth2 / OpenID Connect</span>
</span>
<span className={`${isPendingActivation ? 'text-amber-400' : 'text-emerald-400'} font-medium text-[11px] flex items-center gap-1`}>
<span className={`h-1.5 w-1.5 rounded-full ${isPendingActivation ? 'bg-amber-400' : 'bg-emerald-400'} inline-block`}></span>
{isPendingActivation ? 'Awaiting Admin Enablement' : 'Verified Session'}
</span> </span>
<span className="font-mono text-slate-400 text-[10px]">Audience: pfm-api-dev</span>
</div> </div>
</div> </div>
) : ( ) : (
@@ -227,7 +258,10 @@ export const LandingPage: React.FC = () => {
<div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner"> <div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner">
<Lock className="w-6 h-6" /> <Lock className="w-6 h-6" />
</div> </div>
<div>
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3> <h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
<p className="text-xs text-slate-400 mt-0.5">Enterprise Identity & Access Management</p>
</div>
</div> </div>
{authError && ( {authError && (
@@ -249,11 +283,11 @@ export const LandingPage: React.FC = () => {
</div> </div>
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between"> <div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> <KeyRound className="w-3.5 h-3.5 text-slate-400" />
Duende STS OIDC <span>Single Sign-On (SSO)</span>
</span> </span>
<span className="font-mono text-slate-400 text-[10px]">pfm-api-dev</span> <span className="text-slate-400 text-[11px]">Enterprise Security</span>
</div> </div>
</div> </div>
)} )}
@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -18,24 +19,28 @@ import {
Paperclip, Paperclip,
Check, Check,
Filter, Filter,
Link as LinkIcon,
Edit2,
ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAllIndicators, getAllIndicators,
createIndicator, createIndicator,
recordIndicatorActual, recordIndicatorActual,
getAllEvidence,
uploadEvidenceFile,
updateEvidence,
getAllRisks, getAllRisks,
createRisk, createRisk,
updateRisk,
getProjects, getProjects,
getAllMilestones, getAllMilestones,
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { import {
Indicator, Indicator,
Evidence,
Risk, Risk,
ApprovalStatus, ApprovalStatus,
UnitOfMeasure, UnitOfMeasure,
@@ -77,22 +82,47 @@ const riskSchema = z.object({
type IndicatorFormValues = z.infer<typeof indicatorSchema>; type IndicatorFormValues = z.infer<typeof indicatorSchema>;
type RiskFormValues = z.infer<typeof riskSchema>; type RiskFormValues = z.infer<typeof riskSchema>;
export const WORKFLOW_STAGES = [
{ id: 'planned', label: 'Planned', step: 1, description: 'Milestone scope agreed and deliverables planned' },
{ id: 'in_progress', label: 'In Progress', step: 2, description: 'Activity underway by recipient organisation' },
{ id: 'submitted', label: 'Submitted', step: 3, description: 'Evidence and deliverables submitted by recipient' },
{ id: 'under_review', label: 'Under Review', step: 4, description: 'Programme Manager verifying evidence against criteria' },
{ id: 'verified_paid', label: 'Verified & Paid', step: 5, description: 'Milestone verified and disbursement authorized' },
] as const;
export const MonitoringPage: React.FC = () => { export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { success, error: toastError } = useToast(); const { success, error: toastError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<'indicators' | 'evidence' | 'risks'>('evidence'); const tabParam = searchParams.get('tab');
const fileInputRef = useRef<HTMLInputElement>(null); const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
);
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
useEffect(() => {
const t = searchParams.get('tab');
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
setActiveTab(t);
}
}, [searchParams]);
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
// Modals & States // Modals & States
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false); const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
const [isRiskModalOpen, setIsRiskModalOpen] = useState(false); const [isRiskModalOpen, setIsRiskModalOpen] = useState(false);
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null); const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0); const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
// Figure 10 Reviewer commentary
const [reviewerComment, setReviewerComment] = useState('');
// Queries // Queries
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({ const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
@@ -100,11 +130,6 @@ export const MonitoringPage: React.FC = () => {
queryFn: () => getAllIndicators(), queryFn: () => getAllIndicators(),
}); });
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
queryKey: ['evidence'],
queryFn: () => getAllEvidence(),
});
const { data: risks = [], isLoading: loadingRisks } = useQuery({ const { data: risks = [], isLoading: loadingRisks } = useQuery({
queryKey: ['risks'], queryKey: ['risks'],
queryFn: () => getAllRisks(), queryFn: () => getAllRisks(),
@@ -153,7 +178,7 @@ export const MonitoringPage: React.FC = () => {
indicatorForm.reset(); indicatorForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Failed to create indicator', String(err)); toastError('Failed to create indicator', err);
}, },
}); });
@@ -166,80 +191,77 @@ export const MonitoringPage: React.FC = () => {
setSelectedIndicatorForActual(null); setSelectedIndicatorForActual(null);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Failed to record actual', String(err)); toastError('Failed to record actual', err);
}, },
}); });
const uploadMutation = useMutation({ const openCreateRisk = () => {
mutationFn: (formData: FormData) => uploadEvidenceFile(formData), setEditingRisk(null);
onSuccess: () => { riskForm.reset({
queryClient.invalidateQueries({ queryKey: ['evidence'] }); projectId: projects[0]?.id ? Number(projects[0].id) : 401,
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)'); name: '',
}, description: '',
onError: (err: unknown) => { likelihood: RiskLikelihood.Medium,
toastError('Upload failed', String(err)); impact: RiskImpact.Moderate,
},
}); });
setIsRiskModalOpen(true);
};
const reviewEvidenceMutation = useMutation({ const openEditRisk = (rk: Risk) => {
mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) => setEditingRisk(rk);
updateEvidence({ riskForm.reset({
evidenceId: id, projectId: Number(rk.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
version: 2, name: rk.name || '',
evidenceType: EvidenceType.DeliverableProof, description: rk.description || '',
status, likelihood: (rk.likelihood as RiskLikelihood) ?? RiskLikelihood.Medium,
}), impact: (rk.impact as RiskImpact) ?? RiskImpact.Moderate,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
if (variables.status === ApprovalStatus.Approved) {
success(
'Evidence Approved & Verified',
'Approval recorded. Auto-triggers payment eligibility check in Disbursements.'
);
} else {
success('Rework Requested', 'Evidence returned to recipient for correction.');
}
setReviewerComment('');
},
onError: (err: unknown) => {
toastError('Review update failed', String(err));
},
}); });
setIsRiskModalOpen(true);
};
const riskMutation = useMutation({ const riskMutation = useMutation({
mutationFn: (data: CreateRiskRequest) => createRisk(data), mutationFn: async (data: RiskFormValues) => {
if (editingRisk) {
return updateRisk({
riskId: editingRisk.id,
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
}
return createRisk({
projectId: Number(data.projectId),
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risks'] }); queryClient.invalidateQueries({ queryKey: ['risks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Risk registered', 'Risk item added to project register'); success(
editingRisk ? 'Risk updated' : 'Risk registered',
editingRisk ? 'Risk profile updated in project register' : 'Risk item added to project register'
);
setIsRiskModalOpen(false); setIsRiskModalOpen(false);
setEditingRisk(null);
riskForm.reset(); riskForm.reset();
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toastError('Failed to log risk', String(err)); toastError(editingRisk ? 'Failed to update risk' : 'Failed to log risk', err);
}, },
}); });
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { // Performance indicator thresholds:
const file = e.target.files?.[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
formData.append('projectId', '401');
formData.append('milestoneId', '803');
formData.append('evidenceType', String(uploadEvidenceType));
uploadMutation.mutate(formData);
}
};
// Indicator thresholds from SRS 9.6:
// Green >= 90%, Amber 70-89%, Red < 70% // Green >= 90%, Amber 70-89%, Red < 70%
const getIndicatorStatus = (actual: number, target: number) => { const getIndicatorStatus = (actual: number, target: number) => {
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' }; if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
const pct = (actual / target) * 100; const pct = (actual / target) * 100;
if (pct >= 90) return { label: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }; if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }; if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }; return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
}; };
return ( return (
@@ -248,7 +270,12 @@ export const MonitoringPage: React.FC = () => {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div> <div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
M&E, Evidence & Risk Management <span>M&E, Evidence & Risk Management</span>
<HelpTooltip
title="Monitoring & Evaluation Hub"
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
placement="right"
/>
</h2> </h2>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register. Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
@@ -256,10 +283,19 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeTab === 'contracts' && (
<button
onClick={() => setIsCreateContractModalOpen(true)}
className="inline-flex items-center gap-1.5 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 cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Contract & Upload PDF
</button>
)}
{activeTab === 'indicators' && ( {activeTab === 'indicators' && (
<button <button
onClick={() => setIsIndicatorModalOpen(true)} onClick={() => setIsIndicatorModalOpen(true)}
className="inline-flex items-center gap-1.5 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" className="inline-flex items-center gap-1.5 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 cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Define Indicator Define Indicator
@@ -267,8 +303,8 @@ export const MonitoringPage: React.FC = () => {
)} )}
{activeTab === 'risks' && ( {activeTab === 'risks' && (
<button <button
onClick={() => setIsRiskModalOpen(true)} onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 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" className="inline-flex items-center gap-1.5 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 cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Risk to Register Add Risk to Register
@@ -280,226 +316,91 @@ export const MonitoringPage: React.FC = () => {
{/* Tabs */} {/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto"> <div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button <button
onClick={() => setActiveTab('evidence')} onClick={() => handleTabChange('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'evidence' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'contracts'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
> >
<Paperclip className="w-4 h-4" /> <FileText className="w-4 h-4" />
Milestone Evidence & Review Queue <span>Contracts & PDF Documents</span>
<HelpTooltip
title="Executed Contracts & Vault"
content="Digitally executed bilateral agreements, legal riders, and authoritative PDF repository."
size="sm"
placement="top"
/>
</button> </button>
<button <button
onClick={() => setActiveTab('indicators')} onClick={() => handleTabChange('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'indicators' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'evidence'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<FileCheck className="w-4 h-4" />
<span>Evidence Review & Verification</span>
<HelpTooltip
title="Evidence Review & Verification"
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => handleTabChange('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'indicators'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
> >
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
Indicators & Actuals ({indicators.length}) <span>Indicators & Actuals ({indicators.length})</span>
<HelpTooltip
title="Quantitative Indicators"
content="Baseline vs. target metric progression with automated green/amber/red performance thresholds."
size="sm"
placement="top"
/>
</button> </button>
<button <button
onClick={() => setActiveTab('risks')} onClick={() => handleTabChange('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'risks' className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'risks'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`} }`}
> >
<ShieldAlert className="w-4 h-4" /> <ShieldAlert className="w-4 h-4" />
Risk Register & Heatmap ({risks.length}) <span>Risk Register & Heatmap ({risks.length})</span>
</button> <HelpTooltip
</div> title="Risk Register & Heatmap"
content="5x5 severity matrix scoring likelihood against impact to safeguard public grant investments."
{/* Tab 1: Milestone Evidence Submission & Review (Exact Implementation of Figure 10) */} size="sm"
{activeTab === 'evidence' && ( placement="top"
<div className="space-y-6">
{/* Wireframe Container from SRS Figure 10 */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
{/* Header Bar */}
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
<span className="text-xs font-mono font-semibold tracking-wider text-blue-400 uppercase">
RECIPIENT PORTAL &bull; Project XYZ &bull; Milestone 3: Prototype Delivery
</span>
<span className="text-[11px] text-slate-400 font-mono">Milestone Evidence Submission</span>
</div>
{/* Workflow Progress Steps */}
<div className="p-6 border-b border-slate-800/80 bg-slate-900/40">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center text-xs font-semibold">
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">Planned</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">In Progress</div>
<div className="py-2.5 px-3 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-600/30 ring-2 ring-blue-500/40">
Submitted
</div>
<div className="py-2.5 px-3 rounded-xl bg-amber-500/15 text-amber-300 border border-amber-500/30">
Under Review
</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-900/40 text-slate-500 border border-dashed border-slate-800">
Verified & Paid
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-slate-300">
<span className="font-semibold">Current status:</span>
<span className="font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 font-bold">
SUBMITTED — awaiting Programme Manager review
</span>
</div>
</div>
</div>
{/* Upload Evidence & Attached Evidence Panels (Matching Figure 10) */}
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
{/* Upload Box */}
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white">Upload Evidence</h4>
<div className="flex items-center gap-1.5">
<label className="text-[11px] font-medium text-slate-400">Type:</label>
<select
value={uploadEvidenceType}
onChange={(e) => setUploadEvidenceType(Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[180px] [&>option]:bg-slate-900 [&>option]:text-white"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-slate-400">
Drag & drop or browse files. Accepted formats: PDF, JPG, DOCX, XLSX (Max 25MB).
</p>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-700/80 hover:border-blue-500/80 bg-slate-900/40 hover:bg-blue-500/5 rounded-2xl p-8 text-center cursor-pointer transition flex flex-col items-center justify-center space-y-3"
>
<Upload className="w-8 h-8 text-slate-400" />
<div>
<span className="text-xs font-bold text-white block">Click to Browse or Drag Files Here</span>
<span className="text-[10px] text-slate-500">Automated SHA-256 hash stamp applied on intake</span>
</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl shadow-xs transition"
>
Browse Files
</button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
/> />
</button>
</div> </div>
{/* Attached Evidence List */} {/* Tab 0: Contracts & Legal Documents with PDF Upload */}
<div className="p-6 space-y-4"> {activeTab === 'contracts' && (
<div className="flex items-center justify-between"> <ContractsManagement
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4> isCreateModalOpenExternal={isCreateContractModalOpen}
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span> onCloseCreateModalExternal={() => setIsCreateContractModalOpen(false)}
</div> />
<div className="space-y-2.5">
{evidenceList.map((ev) => (
<div
key={ev.id}
className="p-3 rounded-xl border border-slate-800/80 bg-slate-900/60 flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="font-semibold text-slate-200 block truncate font-mono">
{ev.fileName || `evidence_file_${ev.id}.pdf`}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{ev.fileSize || '3.2 MB'} &bull; v{ev.version} &bull; {getEvidenceTypeLabel(ev.type)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Under Review
</span>
{ev.documentUrl && (
<a
href={ev.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-white p-1 transition"
title="Download evidence file"
>
<Download className="w-3.5 h-3.5" />
</a>
)} )}
</div>
</div>
))}
</div>
</div>
</div>
{/* Programme Manager Review Section (Figure 10) */} {/* Tab 1: Project & Milestone Evidence Review Hub */}
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4"> {activeTab === 'evidence' && (
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4> <EvidenceReviewQueue
initialProjectId={searchParams.get('projectId') || 'all'}
<div> initialMilestoneId={searchParams.get('milestoneId') || 'all'}
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> initialActivityId={searchParams.get('activityId') || 'all'}
Reviewer comment / rejection reason initialUserId={searchParams.get('userId') || 'all'}
</label>
<input
type="text"
value={reviewerComment}
onChange={(e) => setReviewerComment(e.target.value)}
placeholder="Mandatory if requesting rework; optional for approvals..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
/> />
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-3">
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Rejected,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-rose-600/90 hover:bg-rose-600 rounded-xl shadow-lg shadow-rose-600/20 transition"
>
Reject / Rework
</button>
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Approved,
})
}
disabled={reviewEvidenceMutation.isPending}
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"
>
Approve & Verify
</button>
</div>
<p className="text-[11px] text-slate-400 italic">
Approval auto-triggers payment eligibility check.
</p>
</div>
</div>
</div>
</div>
)} )}
{/* Tab 2: Indicators Table (M08) */} {/* Tab 2: Indicators Table (M08) */}
@@ -565,9 +466,33 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
)} )}
{/* Tab 3: Risk Register (M09) */} {/* Tab 3: Risk Register & Heatmap (M09) */}
{activeTab === 'risks' && ( {activeTab === 'risks' && (
<div className="space-y-6">
{/* Live Heatmap Matrix with cell click inspection */}
<RiskHeatmapMatrix
risks={risks}
projects={projects}
onSelectRisk={(r) => openEditRisk(r)}
title="Project Risk Heatmap & Matrix"
subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls"
/>
{/* Risk Register Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden"> <div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-slate-900/60">
<div>
<h4 className="text-sm font-bold text-white">Project Risk Register</h4>
<p className="text-[11px] text-slate-400">Complete itemized audit log of identified risks and residual scores</p>
</div>
<button
onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20"
>
<Plus className="w-3.5 h-3.5" />
Add Risk
</button>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
@@ -579,15 +504,22 @@ export const MonitoringPage: React.FC = () => {
<th className="py-3.5 px-4 text-center">Residual Score</th> <th className="py-3.5 px-4 text-center">Residual Score</th>
<th className="py-3.5 px-4">Risk Owner</th> <th className="py-3.5 px-4">Risk Owner</th>
<th className="py-3.5 px-5">Mitigation Control</th> <th className="py-3.5 px-5">Mitigation Control</th>
<th className="py-3.5 px-4 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/50"> <tbody className="divide-y divide-slate-800/50">
{loadingRisks ? ( {loadingRisks ? (
<tr> <tr>
<td colSpan={7} className="py-8 text-center text-slate-500"> <td colSpan={8} className="py-8 text-center text-slate-500">
Loading risk register... Loading risk register...
</td> </td>
</tr> </tr>
) : risks.length === 0 ? (
<tr>
<td colSpan={8} className="py-8 text-center text-slate-500">
No risks recorded in the register. Click &quot;Add Risk&quot; to log your first risk item.
</td>
</tr>
) : ( ) : (
risks.map((rk) => { risks.map((rk) => {
const score = rk.likelihood * rk.impact; const score = rk.likelihood * rk.impact;
@@ -610,6 +542,15 @@ export const MonitoringPage: React.FC = () => {
<td className="py-3.5 px-5 text-slate-400 text-[11px]"> <td className="py-3.5 px-5 text-slate-400 text-[11px]">
{rk.mitigationAction || 'Standard internal audit controls applied.'} {rk.mitigationAction || 'Standard internal audit controls applied.'}
</td> </td>
<td className="py-3.5 px-4 text-right">
<button
onClick={() => openEditRisk(rk)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700/60 rounded-lg transition"
title="Edit Risk"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr> </tr>
); );
}) })
@@ -618,6 +559,7 @@ export const MonitoringPage: React.FC = () => {
</table> </table>
</div> </div>
</div> </div>
</div>
)} )}
{/* Modal: Define Indicator */} {/* Modal: Define Indicator */}
@@ -626,10 +568,19 @@ export const MonitoringPage: React.FC = () => {
onClose={() => setIsIndicatorModalOpen(false)} onClose={() => setIsIndicatorModalOpen(false)}
title="Define M&E Indicator" title="Define M&E Indicator"
subtitle="Logical Framework & Theory of Change metric" subtitle="Logical Framework & Theory of Change metric"
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
> >
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Project *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Project *</label>
<HelpTooltip
title="Target Project"
content="The project against which this indicator is tracked."
size="sm"
placement="right"
/>
</div>
<select <select
{...indicatorForm.register('projectId')} {...indicatorForm.register('projectId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -643,7 +594,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Indicator Statement *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Indicator Statement *</label>
<HelpTooltip
title="Measurement Statement"
content="Precise description of the result or output being counted or verified."
size="sm"
placement="right"
/>
</div>
<input <input
type="text" type="text"
{...indicatorForm.register('name')} {...indicatorForm.register('name')}
@@ -654,7 +613,15 @@ export const MonitoringPage: React.FC = () => {
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Unit of Measure *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Unit of Measure *</label>
<HelpTooltip
title="Metric Dimension"
content="Counting unit (e.g. Number, Percentage, Currency ZAR, Rating Score)."
size="sm"
placement="top"
/>
</div>
<select <select
{...indicatorForm.register('unitOfMeasure')} {...indicatorForm.register('unitOfMeasure')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -668,7 +635,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Baseline Value *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Baseline Value *</label>
<HelpTooltip
title="Pre-grant Baseline"
content="Observed initial starting value prior to project intervention."
size="sm"
placement="top"
/>
</div>
<input <input
type="number" type="number"
{...indicatorForm.register('baselineAmount')} {...indicatorForm.register('baselineAmount')}
@@ -677,7 +652,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Target Value *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Target Value *</label>
<HelpTooltip
title="Target Benchmark"
content="Contracted goal to achieve before contract completion."
size="sm"
placement="top"
/>
</div>
<input <input
type="number" type="number"
{...indicatorForm.register('targetAmount')} {...indicatorForm.register('targetAmount')}
@@ -711,6 +694,7 @@ export const MonitoringPage: React.FC = () => {
onClose={() => setSelectedIndicatorForActual(null)} onClose={() => setSelectedIndicatorForActual(null)}
title="Record Indicator Actual Value" title="Record Indicator Actual Value"
subtitle={selectedIndicatorForActual?.name || ''} subtitle={selectedIndicatorForActual?.name || ''}
helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating."
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300"> <div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300">
@@ -719,7 +703,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Actual Value to Record *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
<HelpTooltip
title="Monitored Measurement"
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
size="sm"
placement="right"
/>
</div>
<input <input
type="number" type="number"
value={actualInput} value={actualInput}
@@ -754,16 +746,29 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
</Modal> </Modal>
{/* Modal: Add Risk */} {/* Modal: Add or Edit Risk */}
<Modal <Modal
isOpen={isRiskModalOpen} isOpen={isRiskModalOpen}
onClose={() => setIsRiskModalOpen(false)} onClose={() => {
title="Add Risk to Risk Register" setIsRiskModalOpen(false);
subtitle="Identify and score project delivery risks" setEditingRisk(null);
}}
title={editingRisk ? 'Edit Project Risk' : 'Add Risk to Risk Register'}
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
> >
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4"> <form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
{!editingRisk && (
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Context Project"
content="The project directly exposed to this potential threat or operational hazard."
size="sm"
placement="right"
/>
</div>
<select <select
{...riskForm.register('projectId')} {...riskForm.register('projectId')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -775,9 +780,18 @@ export const MonitoringPage: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
)}
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Title *</label>
<HelpTooltip
title="Event Name"
content="Succinct identifier of the uncertainty (e.g. Key Personnel Departure, Import Tariff Hike)."
size="sm"
placement="right"
/>
</div>
<input <input
type="text" type="text"
{...riskForm.register('name')} {...riskForm.register('name')}
@@ -788,7 +802,15 @@ export const MonitoringPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Likelihood (1–5) *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Likelihood (1–5) *</label>
<HelpTooltip
title="Occurrence Probability"
content="Rated 1 (Rare) through 5 (Almost Certain)."
size="sm"
placement="top"
/>
</div>
<select <select
{...riskForm.register('likelihood')} {...riskForm.register('likelihood')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -802,7 +824,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Impact (1–5) *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Impact (1–5) *</label>
<HelpTooltip
title="Consequence Severity"
content="Rated 1 (Insignificant) through 5 (Catastrophic to schedule/budget)."
size="sm"
placement="top"
/>
</div>
<select <select
{...riskForm.register('impact')} {...riskForm.register('impact')}
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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white" 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 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -817,7 +847,15 @@ export const MonitoringPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Description *</label> <div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Description *</label>
<HelpTooltip
title="Root Cause & Mitigation"
content="Detail underlying triggers, preventative actions, and contingency response steps."
size="sm"
placement="right"
/>
</div>
<textarea <textarea
rows={2} rows={2}
{...riskForm.register('description')} {...riskForm.register('description')}
@@ -828,7 +866,10 @@ export const MonitoringPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2"> <div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => setIsRiskModalOpen(false)} onClick={() => {
setIsRiskModalOpen(false);
setEditingRisk(null);
}}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition" className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
> >
Cancel Cancel
@@ -838,7 +879,7 @@ export const MonitoringPage: React.FC = () => {
disabled={riskMutation.isPending} disabled={riskMutation.isPending}
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" 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"
> >
{riskMutation.isPending ? 'Logging...' : 'Log Risk'} {riskMutation.isPending ? 'Saving...' : editingRisk ? 'Update Risk' : 'Log Risk'}
</button> </button>
</div> </div>
</form> </form>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
import axios, { InternalAxiosRequestConfig } from 'axios'; import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config'; import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl(); export const BASE_API_URL = getApiBaseUrl();
@@ -15,14 +17,65 @@ export const apiClient = axios.create({
// Request Interceptor to dynamically set baseURL and attach Bearer Token // Request Interceptor to dynamically set baseURL and attach Bearer Token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration // Dynamically synchronize baseURL from runtime configuration
config.baseURL = getApiBaseUrl(); config.baseURL = getApiBaseUrl();
const token = localStorage.getItem('pfm_access_token'); const token = localStorage.getItem('pfm_access_token');
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
// User Sync Guard:
// All API calls require an authenticated and enabled user in the backend system.
const isSyncRequest = isUserSyncEndpoint(config.url);
if (token && !isSyncRequest) {
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation'));
}
const storedSub = localStorage.getItem('pfm_user_sub') || '';
if (!isUserSyncedInBrowser(storedSub, token)) {
const syncRes = await syncUserAccount(storedSub, token);
if (!syncRes.success) {
return Promise.reject(new Error(syncRes.errorMessage || 'User account sync pending activation'));
}
}
}
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
// Response Interceptor for global HTTP 401 detection
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
const isSyncEndpoint = isUserSyncEndpoint(requestUrl);
// Handle 401 Unauthorized errors
if (!isSyncEndpoint) {
// Working endpoint returned HTTP 401: trigger session expiration prompt
const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
'Authentication session expired or security token invalidated (HTTP 401).';
triggerSessionExpired(detailMsg, requestUrl);
} else {
// User sync endpoint returned HTTP 401: clear cached auth state
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
localStorage.removeItem('pfm_user_sync_record');
localStorage.removeItem('pfm_user_pending_activation');
localStorage.removeItem('pfm_user_pending_activation_reason');
}
}
}
return Promise.reject(error);
}
);
@@ -0,0 +1,154 @@
import { queryClient } from '../lib/queryClient';
export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token';
export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record';
export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation';
export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason';
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This differentiates the user sync / lockout lifecycle from active session working endpoints.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>();
let hasTriggeredSessionExpired = false;
/**
* Dismisses all subordinate UI modals throughout the application.
*/
export function dismissSubordinateModals(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates'));
}
}
/**
* Cancels and avoids the session expiration process and resets its state.
*/
export function cancelSessionExpiration(): void {
hasTriggeredSessionExpired = false;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled'));
}
}
/**
* Checks whether session expiration is currently active.
*/
export function isSessionExpiredState(): boolean {
return hasTriggeredSessionExpired;
}
/**
* Triggers global session expiration when an endpoint returns HTTP 401.
* Prevents multiple modal popups if multiple parallel requests fail with 401.
*/
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
// Ignore HTTP 401 if originating from user sync endpoint
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
// If user is pending account activation, ignore general session expiration
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
if (hasTriggeredSessionExpired) {
return;
}
// Only trigger if a session was previously present in localStorage or state
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (!hasExistingToken) {
return;
}
hasTriggeredSessionExpired = true;
// Dismiss open secondary modals
dismissSubordinateModals();
// Dispatch custom browser event
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('pfm:session-expired', {
detail: { reason: reason || 'HTTP 401 Unauthorized' },
})
);
}
// Notify registered callbacks
listeners.forEach((callback) => {
try {
callback(reason);
} catch (err) {
console.warn('[SessionExpiration] Callback execution error:', err);
}
});
}
/**
* Subscribes a callback to session expiration events.
* Returns an unsubscribe function.
*/
export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Resets the session expiration trigger guard.
*/
export function resetSessionExpiredState(): void {
hasTriggeredSessionExpired = false;
}
/**
* Clears all persistent tokens, user claims, synchronization metadata,
* and react-query caches, then securely redirects to the landing page.
*/
export function clearSessionAndRedirectToLanding(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch (err) {
console.warn('[SessionExpiration] Storage cleanup warning:', err);
}
try {
queryClient.clear();
} catch (err) {
console.warn('[SessionExpiration] QueryClient clear warning:', err);
}
resetSessionExpiredState();
// Redirect to landing page
window.location.replace('/');
}
@@ -1,164 +1,218 @@
import Hashids from 'hashids';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from './httpClient';
import { queryClient } from '../lib/queryClient'; const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
/** /**
* Deterministic fallback encoder for offline/preview sessions * Deterministic Hashids configuration matching pfm-api HasherSettings.
* Generates an obfuscated, unique HashID string with salt * Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/ */
function generateDeterministicHashId(id: number | string, context: string): string { export const HASHER_SETTINGS = {
const str = `${context}:${id}:pfm-salt-2026`; minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
let hash = 5381; salt:
for (let i = 0; i < str.length; i++) { (env.VITE_HASHER_SALT as string) ||
hash = ((hash << 5) + hash) + str.charCodeAt(i); 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
hash = hash & hash; // Convert to 32bit integer };
// 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;
} }
const positiveHash = Math.abs(hash); if (minLength !== undefined && minLength !== null && minLength !== '') {
const base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const parsed = Number(minLength);
let encoded = ''; if (!isNaN(parsed) && parsed > 0) {
let num = positiveHash; HASHER_SETTINGS.minHashLength = parsed;
while (num > 0) { updated = true;
encoded = base62Chars[num % 62] + encoded; }
num = Math.floor(num / 62); }
if (updated) {
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
} }
const prefix = context ? context.slice(0, 3).toLowerCase() : 'id';
return `${prefix}_${encoded.padStart(8, 'x')}`;
} }
/** /**
* Invokes the backend tools encoding endpoint to retrieve the designated HashID string * 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.
*/ */
async function fetchHashIdFromEndpoint(id: number | string, context = 'general'): Promise<string> { 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 === '') { if (id === undefined || id === null || id === '') {
return ''; return '';
} }
try { // If already a number
// Attempt standard tools encoding endpoint variations if (typeof id === 'number') {
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>( if (isNaN(id) || !isFinite(id) || id < 0) return '';
`/v1/api/tools/encode/${id}`, return hasher.encode(id);
{
params: { context, type: context },
timeout: 4000,
} }
);
if (typeof response.data === 'string' && response.data.trim()) { const str = String(id).trim();
return response.data.trim(); if (!str) return '';
// Check if string is already a valid HashID
if (isAlreadyHashId(str)) {
return str;
} }
if (response.data && typeof response.data === 'object') {
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value; // Check if string is pure digits (e.g. "123")
if (candidate) return candidate; if (/^\d+$/.test(str)) {
}
} catch {
// Attempt query param variant
try { try {
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>( const num = parseInt(str, 10);
'/v1/api/tools/encode', if (!isNaN(num) && num >= 0) {
{ return hasher.encode(num);
params: { id, context, type: context },
timeout: 4000,
}
);
if (typeof response.data === 'string' && response.data.trim()) {
return response.data.trim();
}
if (response.data && typeof response.data === 'object') {
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
if (candidate) return candidate;
} }
} catch { } catch {
// Fallback to deterministic HashID generator // Fall through
} }
} }
// Graceful fallback guaranteeing valid HashID output during preview // Non-numeric identifiers (UUIDs, codes) returned as-is
return generateDeterministicHashId(id, context); return str;
} }
/** /**
* Invokes the backend tools decoding endpoint * Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/ */
async function fetchDecodedIdFromEndpoint(hashId: string, context = 'general'): Promise<number | string> { export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (!hashId) return ''; if (hashId === undefined || hashId === null || hashId === '') return '';
try { if (typeof hashId === 'number') return hashId;
const response = await apiClient.get<{ id?: number | string; decoded?: number | string } | number | string>(
`/v1/api/tools/decode/${encodeURIComponent(hashId)}`, const str = String(hashId).trim();
{ params: { context }, timeout: 4000 } if (!str) return '';
);
if (typeof response.data === 'number' || typeof response.data === 'string') { // If already pure integer string
return response.data; if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
} }
if (response.data && typeof response.data === 'object') {
if (response.data.id !== undefined) return response.data.id; try {
if (response.data.decoded !== undefined) return response.data.decoded; const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
} }
} catch { } catch {
// Passthrough or fallback // If decoding fails, return raw string
} }
return hashId;
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 = { export const toolsService = {
/** /**
* Encodes a raw ID to a HashID, cached client-side in TanStack Query for the active session * Synchronous encoding
*/ */
async encodeId(id: number | string, context = 'general'): Promise<string> { encodeIdSync(id: number | string | null | undefined): string {
if (id === undefined || id === null || id === '') return ''; return encodeHashIdSync(id);
},
const idStr = String(id); /**
return queryClient.fetchQuery({ * Synchronous decoding
queryKey: ['hashId', context, idStr], */
queryFn: () => fetchHashIdFromEndpoint(id, context), decodeIdSync(hashId: string | number | null | undefined): number | string {
staleTime: Infinity, // Caches client-side during the active session to eliminate redundant round trips return decodeHashIdSync(hashId);
gcTime: 1000 * 60 * 60 * 24, // Keep in memory },
});
/**
* 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 * Decodes a HashID string back to original identifier
*/ */
async decodeId(hashId: string, context = 'general'): Promise<number | string> { async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
if (!hashId) return ''; return decodeHashIdSync(hashId);
return queryClient.fetchQuery({
queryKey: ['decodeHashId', context, hashId],
queryFn: () => fetchDecodedIdFromEndpoint(hashId, context),
staleTime: Infinity,
gcTime: 1000 * 60 * 60 * 24,
});
}, },
/** /**
* Specific convenience resolvers for domain entities * Domain-specific convenience resolvers for entities
*/ */
async resolveProjectId(projectId: number | string): Promise<string> { async resolveProjectId(projectId: number | string): Promise<string> {
return this.encodeId(projectId, 'project'); return encodeHashIdSync(projectId);
}, },
async resolveOrganisationId(organisationId: number | string): Promise<string> { async resolveOrganisationId(organisationId: number | string): Promise<string> {
return this.encodeId(organisationId, 'organisation'); return encodeHashIdSync(organisationId);
}, },
async resolveProgrammeId(programmeId: number | string): Promise<string> { async resolveProgrammeId(programmeId: number | string): Promise<string> {
return this.encodeId(programmeId, 'programme'); return encodeHashIdSync(programmeId);
}, },
async resolvePortfolioId(portfolioId: number | string): Promise<string> { async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return this.encodeId(portfolioId, 'portfolio'); return encodeHashIdSync(portfolioId);
}, },
async resolveAwardId(awardId: number | string): Promise<string> { async resolveAwardId(awardId: number | string): Promise<string> {
return this.encodeId(awardId, 'award'); return encodeHashIdSync(awardId);
}, },
async resolveContractId(contractId: number | string): Promise<string> { async resolveContractId(contractId: number | string): Promise<string> {
return this.encodeId(contractId, 'contract'); return encodeHashIdSync(contractId);
}, },
/** /**
* Resolves query parameters or path variables encoded as HashIDs * Resolves query parameters or path variables encoded as HashIDs before performing API calls
* before performing API calls
*/ */
async resolveHashedParams<T extends Record<string, any>>( async resolveHashedParams<T extends Record<string, any>>(
params: T, params: T,
@@ -168,18 +222,17 @@ export const toolsService = {
for (const key of keysToHash) { for (const key of keysToHash) {
const val = params[key]; const val = params[key];
if (val !== undefined && val !== null) { if (val !== undefined && val !== null) {
const contextStr = String(key).replace(/Id$/i, '').toLowerCase(); resolved[key] = encodeHashIdSync(val);
resolved[key] = await this.encodeId(val, contextStr);
} }
} }
return resolved; return resolved;
}, },
/** /**
* Pre-fetches and primes cache for a collection of IDs * Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
*/ */
async primeCache(items: { id: number | string; context: string }[]): Promise<void> { async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
await Promise.all(items.map((item) => this.encodeId(item.id, item.context))); // No-op - instant deterministic computation
}, },
}; };
@@ -189,7 +242,7 @@ export const toolsService = {
export function useHashId(id?: number | string | null, context = 'general') { export function useHashId(id?: number | string | null, context = 'general') {
return useQuery({ return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => (id !== undefined && id !== null ? toolsService.encodeId(id, context) : Promise.resolve('')), queryFn: () => Promise.resolve(encodeHashIdSync(id)),
enabled: id !== undefined && id !== null && id !== '', enabled: id !== undefined && id !== null && id !== '',
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -0,0 +1,363 @@
import { apiClient } from './httpClient';
export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record';
export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation';
export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason';
export interface UserSyncRecord {
sub: string;
syncedAt: number;
syncedEndpoint: string;
tokenSnippet: string;
status: 'synced';
userData?: any;
}
export interface UserSyncProfile {
id?: string;
sub?: string;
email?: string | null;
name?: string | null;
role?: string | null;
tenant?: string | null;
tenant_id?: number | string | null;
organisationId?: number | string | null;
organisationName?: string | null;
}
export interface UserSyncResult {
success: boolean;
isUnauthorized?: boolean;
isActivationPending?: boolean;
endpoint?: string;
data?: any;
errorMessage?: string;
}
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
/**
* Checks if the user is currently flagged in the browser as pending administrator activation / lockout.
*/
export function isActivationPending(): boolean {
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
} catch {
return false;
}
}
/**
* Gets the reason/error message recorded when activation check was last pending.
*/
export function getActivationPendingReason(): string | null {
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch {
return null;
}
}
/**
* Sets or clears the pending activation flag in browser persistent storage.
*/
export function setActivationPending(pending: boolean, reason?: string): void {
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
if (reason) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
}
} else {
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
}
} catch (err) {
console.warn('[UserSync] Failed to update activation flag:', err);
}
}
/**
* Checks if the current authenticated user has already been synchronized
* and stored in the browser's persistent storage.
*
* This prevents subsequent API calls, page refreshes, and navigations
* from creating a hotspot on the backend sync user endpoint.
*/
export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean {
try {
// If flagged as pending activation, they are NOT considered synced
if (isActivationPending()) {
return false;
}
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const record: UserSyncRecord = JSON.parse(raw);
if (!record || record.status !== 'synced') return false;
// Check if the stored sync matches the current user sub (if specified)
if (currentSub && record.sub && record.sub !== currentSub) {
return false;
}
// Check if the token snippet matches (if token changed, e.g. new session login)
if (currentToken && record.tokenSnippet) {
const snippet = currentToken.slice(-16);
if (record.tokenSnippet !== snippet) {
return false;
}
}
return true;
} catch {
return false;
}
}
/**
* Persists the user sync record into browser storage.
*/
export function saveUserSyncInBrowser(
sub: string,
token: string | null | undefined,
endpoint: string,
userData?: any
): void {
try {
const record: UserSyncRecord = {
sub: sub || 'authenticated_user',
syncedAt: Date.now(),
syncedEndpoint: endpoint,
tokenSnippet: token ? token.slice(-16) : '',
status: 'synced',
userData: userData || undefined,
};
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
}
}
/**
* Clears the user sync record from browser storage (used on logout or when activation fails).
*/
export function clearUserSyncInBrowser(): void {
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch {}
}
/**
* Clears only the successful sync record, leaving activation pending intact.
*/
export function clearSuccessfulSyncRecord(): void {
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}
}
/**
* Retrieves the current sync record from browser storage if present.
*/
export function getUserSyncRecord(): UserSyncRecord | null {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return null;
return JSON.parse(raw) as UserSyncRecord;
} catch {
return null;
}
}
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
'/v1/api/integration/sync',
'/v1/api/users/sync',
'/v1/api/user/sync',
];
/**
* Determines whether a given URL or request path targets a user sync endpoint.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/integration/sync') ||
clean.includes('/users/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
/**
* Executes the backend user sync request across available candidate endpoints.
*/
async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
const parsedTenantId =
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
? Number(rawTenantId)
: undefined;
const resolvedOrgId =
parsedTenantId !== undefined
? parsedTenantId
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
? Number(profile.organisationId)
: undefined;
const payload = {
id: userSub || profile?.id || profile?.sub,
sub: userSub || profile?.sub,
email: profile?.email || undefined,
name: profile?.name || undefined,
role: profile?.role || undefined,
tenant: profile?.tenant || undefined,
tenant_id: parsedTenantId,
organisationId: resolvedOrgId,
};
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
let lastError: any = null;
for (const endpoint of candidateEndpoints) {
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);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// Fail fast on token expiration/invalid
if (status === 401) {
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');
}
/**
* Synchronizes the user account with the backend system.
* Handles:
* - HTTP 401 -> Returns isUnauthorized: true (triggering redirection to landing page)
* - HTTP 403 / failure -> Flags isActivationPending: true (triggering non-dismissable activation modal)
* - Success -> Caches sync record in browser, clears pending activation flag
*/
export async function syncUserAccount(
userSub?: string,
token?: string | null,
profile?: UserSyncProfile,
force = false
): Promise<UserSyncResult> {
const effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null);
const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
// If already synced in the browser, no pending activation, and not forced, return immediately.
// Avoids hotspotting the sync endpoint.
if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) {
return {
success: true,
endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync',
data: getUserSyncRecord()?.userData,
};
}
// Deduplicate concurrent sync calls
if (inFlightSyncPromise) {
return inFlightSyncPromise;
}
inFlightSyncPromise = (async (): Promise<UserSyncResult> => {
try {
const result = await executeBackendSync(effectiveSub, profile);
// Success! Clear any activation lockout, save valid sync record to browser
setActivationPending(false);
saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data);
return {
success: true,
endpoint: result.endpoint,
data: result.data,
};
} catch (err: any) {
const status = err?.response?.status;
// Handle HTTP 401: Normal process of routing user to landing page
if (status === 401) {
clearUserSyncInBrowser();
setActivationPending(false);
return {
success: false,
isUnauthorized: true,
errorMessage: 'Authentication session expired or unauthorized (HTTP 401).',
};
}
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
clearSuccessfulSyncRecord();
const serverMsg =
err?.response?.data?.message ||
err?.response?.data?.detail ||
err?.response?.data?.error ||
err?.message ||
'Account is not yet enabled by the administrator.';
setActivationPending(true, serverMsg);
return {
success: false,
isUnauthorized: false,
isActivationPending: true,
errorMessage: serverMsg,
};
} finally {
inFlightSyncPromise = null;
}
})();
return inFlightSyncPromise;
}
/**
* Backward-compatible helper returning boolean success.
*/
export async function ensureUserSynced(
userSub?: string,
token?: string | null,
profile?: UserSyncProfile,
force = false
): Promise<boolean> {
const result = await syncUserAccount(userSub, token, profile, force);
return result.success;
}
+22 -1
View File
@@ -231,7 +231,8 @@ export interface ProblemDetails {
status?: number | null; status?: number | null;
detail?: string | null; detail?: string | null;
instance?: string | null; instance?: string | null;
errors?: Record<string, string[]>; errors?: Record<string, string[] | string> | any;
[key: string]: any;
} }
export interface Dashboard { export interface Dashboard {
@@ -260,6 +261,8 @@ export interface User {
name?: string; name?: string;
organisationId?: number | string; organisationId?: number | string;
organisationName?: string; organisationName?: string;
tenant?: string;
tenant_id?: number;
} }
export type SystemUser = User; export type SystemUser = User;
@@ -285,6 +288,7 @@ export interface UserFilterRequest {
email?: string | null; email?: string | null;
sortBy?: string | null; sortBy?: string | null;
sortDescending?: boolean; sortDescending?: boolean;
organisationId?: number | string | null;
} }
export interface Organisation { export interface Organisation {
@@ -443,6 +447,18 @@ export interface CreateActivityRequest {
performedAt?: string | null; performedAt?: string | null;
} }
export interface UpdateActivityRequest {
activityId: number | string;
projectId?: number | string;
milestoneId?: number | string | null;
category: string;
amount: number;
title: string;
description: string;
performedAt?: string | null;
status?: ApprovalStatus;
}
export interface Milestone { export interface Milestone {
id: number | string; id: number | string;
projectId: number | string; projectId: number | string;
@@ -516,6 +532,11 @@ export interface CreateContractRequest {
expiresAt: string; expiresAt: string;
} }
export interface UpdateContractRequest {
contractId: number | string;
status: ContractStatus;
}
export interface ExecuteContractRequest { export interface ExecuteContractRequest {
contractId: number | string; contractId: number | string;
externalSignatureId: string; externalSignatureId: string;
@@ -0,0 +1,532 @@
import axios, { AxiosError } from 'axios';
import { ProblemDetails } from '../types';
export type ErrorCategory =
| 'validation'
| 'creation'
| 'update'
| 'security'
| 'notFound'
| 'conflict'
| 'server'
| 'general';
export interface FriendlyError {
title: string;
message: string;
statusCode?: number;
isProblemDetails: boolean;
category: ErrorCategory;
categoryLabel?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
technicalDetails?: string;
}
/**
* Converts camelCase or PascalCase field names to human-readable titles.
* e.g. "HashId" -> "Resource Identifier", "startDate" -> "Start Date"
*/
function humanizeFieldName(field: string): string {
if (!field) return 'Field';
const customMappings: Record<string, string> = {
hashid: 'Resource Identifier (HashId)',
id: 'Record ID',
sub: 'User Identity',
orgid: 'Organisation',
organisationid: 'Organisation',
portfolioid: 'Portfolio',
programmeid: 'Programme',
projectid: 'Project',
grantid: 'Award',
awardid: 'Award',
contractid: 'Contract',
budgetid: 'Budget',
disbursementid: 'Disbursement',
indicatorid: 'Indicator',
riskid: 'Risk',
email: 'Email Address',
name: 'Name',
title: 'Title',
code: 'Reference Code',
startdate: 'Start Date',
enddate: 'End Date',
targetdate: 'Target Date',
actualdate: 'Actual Date',
amount: 'Amount',
budgetamount: 'Budget Amount',
targetvalue: 'Target Value',
actualvalue: 'Actual Value',
baselinevalue: 'Baseline Value',
status: 'Status',
governancestatus: 'Governance Status',
contractstatus: 'Contract Status',
disbursementstatus: 'Disbursement Status',
};
const lower = field.toLowerCase().replace(/[^a-z0-9]/g, '');
if (customMappings[lower]) {
return customMappings[lower];
}
// Insert space before uppercase letters
const spaced = field.replace(/([a-z])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Checks if a string contains raw HTTP request/response headers or dump.
*/
export function isRawHttpOutput(text: string): boolean {
if (!text || typeof text !== 'string') return false;
// If it has multiple header-like lines or standard HTTP request/response signatures
const headerPatterns = [
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
];
let matches = 0;
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
if (headerPatterns.some((p) => p.test(trimmed))) {
matches++;
}
}
}
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
}
/**
* Redacts sensitive tokens and credentials from technical traces.
*/
export function sanitizeTechnicalDetails(text: string): string {
if (!text) return '';
return text
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
}
/**
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
*/
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
// If text is a raw HTTP dump, do not return it as clean user text
if (isRawHttpOutput(text)) {
return '';
}
const cleaned = text
.replace(/^AxiosError:\s*/i, '')
.replace(/^Error:\s*/i, '')
.replace(/Request failed with status code \d+/i, '')
.replace(/https?:\/\/[^\s]+/g, '')
.replace(/System\.[a-zA-Z0-9_.]+/g, '')
.replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '')
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
.trim();
// In this system, ProblemDetails title is always "Error" — ignore trivial or generic "Error" strings
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
return '';
}
return cleaned;
}
/**
* Checks if a response object conforms to RFC 7807 / RFC 9457 ProblemDetails.
*/
export function isProblemDetailsObject(data: any): data is ProblemDetails {
if (!data || typeof data !== 'object') return false;
return (
'title' in data ||
'detail' in data ||
'status' in data ||
'type' in data ||
'errors' in data ||
'Errors' in data ||
'errorList' in data
);
}
/**
* Detects whether the user action context was an entity creation or an update.
*/
function detectOperationIntent(
method?: string,
actionTitle?: string,
url?: string
): 'creation' | 'update' | 'deletion' | 'general' {
const normMethod = (method || '').toUpperCase();
const text = `${actionTitle || ''} ${url || ''}`.toLowerCase();
if (
normMethod === 'POST' ||
text.includes('create') ||
text.includes('registration') ||
text.includes('register') ||
text.includes('dispatch') ||
text.includes('add ') ||
text.includes('new ') ||
text.includes('upload') ||
text.includes('record actual') ||
text.includes('log risk')
) {
return 'creation';
}
if (
normMethod === 'PUT' ||
normMethod === 'PATCH' ||
text.includes('update') ||
text.includes('edit') ||
text.includes('save') ||
text.includes('status') ||
text.includes('approve') ||
text.includes('execute') ||
text.includes('modify')
) {
return 'update';
}
if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) {
return 'deletion';
}
return 'general';
}
/**
* Extracts and formats human-friendly error guidance from any error,
* accessing the ProblemDetails `errors` array directly to display the error.
* In this system the title field is always "Error", so no generic error testing is performed.
*/
export function parseFriendlyError(
error: unknown,
fallbackActionTitle = 'Action Failed'
): FriendlyError {
let statusCode: number | undefined;
let rawTitle: string | undefined;
let rawDetail: string | undefined;
let rawErrors: any = undefined;
let isProblemDetails = false;
let rawResponseData: any = null;
let technicalSummary = '';
let httpMethod = '';
let requestUrl = '';
// 1. Inspect Axios error structure
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<any>;
statusCode = axiosErr.response?.status;
rawResponseData = axiosErr.response?.data;
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
requestUrl = axiosErr.config?.url || '';
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
if (rawResponseData && typeof rawResponseData === 'object') {
const dataObj = rawResponseData as any;
if (isProblemDetailsObject(dataObj)) {
isProblemDetails = true;
rawTitle = dataObj.title || dataObj.Title || undefined;
rawDetail = dataObj.detail || dataObj.Detail || undefined;
statusCode = dataObj.status || dataObj.Status || statusCode;
rawErrors = dataObj.errors ?? dataObj.Errors ?? dataObj.errorList ?? dataObj.ErrorList;
} else if (dataObj.message || dataObj.error || dataObj.errors || dataObj.Errors) {
rawDetail = dataObj.message || dataObj.error;
rawErrors = dataObj.errors ?? dataObj.Errors;
} else if (Array.isArray(dataObj)) {
rawErrors = dataObj;
}
} else if (typeof rawResponseData === 'string') {
if (isRawHttpOutput(rawResponseData)) {
technicalSummary = sanitizeTechnicalDetails(rawResponseData);
rawDetail = undefined;
} else {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
} catch {
if (!rawResponseData.includes('<html')) {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
}
}
}
// Network disconnection or request timeout (no response received)
if (!axiosErr.response) {
if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
return {
title: 'Request Timed Out',
message: 'The server took too long to respond. Please check your network and try again.',
statusCode: 408,
isProblemDetails: false,
category: 'server',
categoryLabel: 'Timeout',
technicalDetails: technicalSummary || axiosErr.message,
};
}
return {
title: 'Connection Unavailable',
message: 'Unable to reach the system service. Please verify your internet connection or try again in a few moments.',
statusCode: 0,
isProblemDetails: false,
category: 'server',
categoryLabel: 'Network',
technicalDetails: technicalSummary || axiosErr.message,
};
}
} else if (error && typeof error === 'object') {
// 2. Direct ProblemDetails object or custom error object
const obj = error as any;
if (isProblemDetailsObject(obj)) {
isProblemDetails = true;
rawTitle = obj.title || obj.Title || undefined;
rawDetail = obj.detail || obj.Detail || undefined;
statusCode = obj.status || obj.Status || undefined;
rawErrors = obj.errors ?? obj.Errors ?? obj.errorList ?? obj.ErrorList;
} else if (obj.response?.data && isProblemDetailsObject(obj.response.data)) {
isProblemDetails = true;
const respData = obj.response.data as any;
rawTitle = respData.title || respData.Title || undefined;
rawDetail = respData.detail || respData.Detail || undefined;
statusCode = obj.response.status || respData.status || respData.Status;
rawErrors = respData.errors ?? respData.Errors ?? respData.errorList ?? respData.ErrorList;
} else if (Array.isArray(obj)) {
rawErrors = obj;
} else if (obj.message) {
rawDetail = obj.message;
const statusMatch = obj.message.match(/status code (\d{3})/i);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
}
}
} else if (typeof error === 'string') {
// 3. String error
const statusMatch = error.match(/status code (\d{3})/i);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
}
rawDetail = cleanTechnicalJargon(error);
}
// Extract errors list directly from ProblemDetails errors array / dictionary
const fieldErrors: Array<{ field: string; message: string }> = [];
const errorsList: string[] = [];
if (rawErrors) {
if (Array.isArray(rawErrors)) {
rawErrors.forEach((item) => {
if (typeof item === 'string' && item.trim()) {
const cleaned = cleanTechnicalJargon(item.trim());
if (cleaned) errorsList.push(cleaned);
} else if (item && typeof item === 'object') {
const msg =
item.message ||
item.errorMessage ||
item.detail ||
item.error ||
item.description ||
JSON.stringify(item);
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) errorsList.push(cleaned);
}
});
} else if (typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const isNumericKey = /^\d+$/.test(key);
const fieldTitle = isNumericKey ? '' : humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) {
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
});
} else if (typeof val === 'string' && val.trim()) {
const cleaned = cleanTechnicalJargon(val.trim());
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
}
} else if (typeof rawErrors === 'string' && rawErrors.trim()) {
const cleaned = cleanTechnicalJargon(rawErrors.trim());
if (cleaned) errorsList.push(cleaned);
}
}
// Detect operation intent (creation, update, deletion, general)
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
let friendlyTitle = fallbackActionTitle;
let friendlyMessage = '';
let category: ErrorCategory = 'general';
let categoryLabel: string | undefined = undefined;
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
// In this system, ProblemDetails allows access to the errors array to display the error directly if available
if (errorsList.length > 0) {
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Validation'
: operationIntent === 'update'
? 'Validation'
: 'Validation';
// Display the error directly
if (errorsList.length === 1) {
friendlyMessage = errorsList[0];
} else {
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
}
// If detail provides extra non-duplicate context, include it
if (cleanDetail && !errorsList.some((e) => e.toLowerCase() === cleanDetail.toLowerCase())) {
friendlyMessage = `${cleanDetail}\n\n${friendlyMessage}`;
}
} else if (cleanDetail) {
// If no errors array, but detail is available, display detail directly
friendlyMessage = cleanDetail;
if (statusCode === 403) {
category = 'security';
categoryLabel = 'Access Denied';
} else if (statusCode === 404) {
category = 'notFound';
categoryLabel = 'Not Found';
} else if (statusCode === 409) {
category = 'conflict';
categoryLabel = 'Conflict';
} else if (statusCode && statusCode >= 500) {
category = 'server';
categoryLabel = 'Service Issue';
}
} else {
// Fallback status-specific messaging when neither errors array nor detail is available
switch (statusCode) {
case 403:
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
break;
case 404:
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
case 400:
if (operationIntent === 'creation') {
category = 'creation';
categoryLabel = 'Creation Error';
friendlyMessage =
'The new record could not be created. Please review the input parameters and prerequisites.';
} else if (operationIntent === 'update') {
category = 'update';
categoryLabel = 'Update Error';
friendlyMessage =
'The record could not be updated. It may be locked or the requested status transition is not permitted.';
} else {
category = 'general';
categoryLabel = 'Request Error';
friendlyMessage =
'The request could not be processed due to invalid parameters or business rule requirements.';
}
break;
case 409:
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
'This action conflicts with the current status of the record. Please refresh the page and try again.';
break;
case 422:
category = 'validation';
categoryLabel = 'Incomplete Data';
friendlyMessage =
'The submitted information could not be processed. Please verify that all required fields are complete.';
break;
case 429:
category = 'server';
categoryLabel = 'Rate Limit';
friendlyTitle = 'System Busy';
friendlyMessage =
'The system received too many requests in a short time. Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
category = 'server';
categoryLabel = 'Service Issue';
friendlyTitle = 'Service Temporarily Unavailable';
friendlyMessage =
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
break;
default:
friendlyMessage =
'An unexpected issue occurred while processing this request. Please try again or contact support.';
break;
}
}
// Ensure title is friendly and not an uncaught technical string
if (friendlyTitle.includes('AxiosError') || friendlyTitle.includes('status code')) {
friendlyTitle = fallbackActionTitle;
}
return {
title: friendlyTitle,
message: friendlyMessage,
statusCode,
isProblemDetails,
category,
categoryLabel,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}
+77 -5
View File
@@ -1,21 +1,93 @@
import basicSsl from '@vitejs/plugin-basic-ssl';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { defineConfig } from 'vite'; import {defineConfig} from 'vite';
export default defineConfig(async ({ command }) => {
// In containerized cloud environments where the reverse proxy / ingress terminates TLS externally,
// Vite serves over HTTP on port 3000.
// For local development, HTTPS can be enabled.
const isAiStudioContainer = Boolean(
process.env.APPLET_ID ||
process.env.K_SERVICE ||
process.env.CONTROL_PLANE_PORT
);
const isHttps = !isAiStudioContainer && (
process.env.HTTPS === 'true' ||
process.env.VITE_ENABLE_HTTPS === 'true' ||
(process.env.HTTPS !== 'false' && process.env.VITE_ENABLE_HTTPS !== 'false')
);
const plugins: any[] = [
react(),
tailwindcss(),
];
// Only load SSL plugin when running the local dev server with HTTPS enabled
if (command === 'serve' && isHttps) {
try {
const basicSslModule: any = await import('@vitejs/plugin-basic-ssl');
const basicSsl = basicSslModule.default || basicSslModule;
plugins.push(basicSsl());
} catch {
console.warn(
'[vite.config] @vitejs/plugin-basic-ssl is not installed; running dev server over HTTP. Run `yarn add -D @vitejs/plugin-basic-ssl` to enable local HTTPS.'
);
}
}
export default defineConfig(() => {
return { return {
plugins: [react(), tailwindcss(), basicSsl()], plugins,
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),
}, },
}, },
server: { server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
watch: process.env.DISABLE_HMR === 'true' ? null : {}, watch: process.env.DISABLE_HMR === 'true' ? null : {},
}, },
build: {
rollupOptions: {
onwarn(warning, defaultHandler) {
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
if (
warning.code === 'INVALID_ANNOTATION' &&
(warning.id?.includes('zod') || warning.message?.includes('@__PURE__') || warning.loc?.file?.includes('zod'))
) {
return;
}
defaultHandler(warning);
},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) {
return 'vendor-react';
}
if (id.includes('@tanstack')) {
return 'vendor-query';
}
if (id.includes('oidc-client-ts')) {
return 'vendor-auth';
}
if (id.includes('lucide-react')) {
return 'vendor-icons';
}
if (id.includes('zod') || id.includes('react-hook-form') || id.includes('@hookform')) {
return 'vendor-forms';
}
if (id.includes('axios')) {
return 'vendor-http';
}
}
},
},
},
},
preview: {
port: 3000,
host: '0.0.0.0',
},
}; };
}); });
+12 -12
View File
@@ -785,7 +785,7 @@
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.3" "@tailwindcss/oxide-win32-arm64-msvc" "4.3.3"
"@tailwindcss/oxide-win32-x64-msvc" "4.3.3" "@tailwindcss/oxide-win32-x64-msvc" "4.3.3"
"@tailwindcss/vite@^4.3.3": "@tailwindcss/vite@^4.1.14":
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.3.tgz#1f4037badcb5e6c6bb574d3d8a0172c83f26b97f" resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.3.tgz#1f4037badcb5e6c6bb574d3d8a0172c83f26b97f"
integrity sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw== integrity sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==
@@ -942,11 +942,6 @@
"@types/node" "*" "@types/node" "*"
"@types/send" "<1" "@types/send" "<1"
"@vitejs/plugin-basic-ssl@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz#f505b1c197d8cb4fd6e213a78847574ecc51e2f9"
integrity sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==
"@vitejs/plugin-react@^5.0.4": "@vitejs/plugin-react@^5.0.4":
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f"
@@ -989,10 +984,10 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
autoprefixer@^10.5.6: autoprefixer@^10.4.21:
version "10.5.6" version "10.6.1"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.6.tgz#b613978f41eb58f0b3719ff1e21f993875e86a75" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.6.1.tgz#437cea1a0b96cedc4065c05327b728b1594faf27"
integrity sha512-HiH4oYNc5+DQEx/b8FPfMj+WHH/WWUBmbN5r1Uf/ixtfyFkk+wGIlfJT/RD5eAyzlwkeYRtZUCI8qrEi26pl2g== integrity sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==
dependencies: dependencies:
browserslist "^4.28.9" browserslist "^4.28.9"
caniuse-lite "^1.0.30001810" caniuse-lite "^1.0.30001810"
@@ -1529,6 +1524,11 @@ has-tostringtag@^1.0.2:
dependencies: dependencies:
has-symbols "^1.0.3" has-symbols "^1.0.3"
hashids@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/hashids/-/hashids-2.3.0.tgz#af92c9cdb093c0ed3542cf0dcbd6c289125df387"
integrity sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==
hasown@^2.0.2, hasown@^2.0.4: hasown@^2.0.2, hasown@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
@@ -1875,7 +1875,7 @@ postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.5.28, postcss@^8.5.3: postcss@^8.5.3:
version "8.5.28" version "8.5.28"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9"
integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
@@ -2131,7 +2131,7 @@ statuses@~2.0.1, statuses@~2.0.2:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
tailwindcss@4.3.3, tailwindcss@^4.3.3: tailwindcss@4.3.3, tailwindcss@^4.1.14:
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55"
integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ== integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==
@@ -2,6 +2,8 @@ namespace PostFundManagement.Domain.Extensions;
public static class Constants public static class Constants
{ {
public const string ServiceCachingFeature = "EnableServiceCaching";
public const string MainScope = "ApiScope"; public const string MainScope = "ApiScope";
public const string CorsPolicy = "AllowConfiguredOrigins"; public const string CorsPolicy = "AllowConfiguredOrigins";
@@ -8,15 +8,15 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea
{ {
try try
{ {
var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName, cancellationToken); var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if(scheduler == null) if (scheduler == null)
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found."); return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
if (!scheduler.IsStarted) if (scheduler.Status != SchedulerStatus.Running)
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running"); return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running");
await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken); await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
return HealthCheckResult.Healthy($"{DefaultSchedulerName} Quartz scheduler is ready"); return HealthCheckResult.Healthy($"{DefaultSchedulerName} Quartz scheduler is ready");
} }
@@ -12,9 +12,10 @@
<!-- Transformation --> <!-- Transformation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Humanizer" Version="3.0.10" /> <PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.9.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
<PackageReference Include="Scrutor" Version="7.0.0" /> <PackageReference Include="Scrutor" Version="7.0.0" />
<Using Include="Humanizer" /> <Using Include="Humanizer" />
@@ -27,12 +28,12 @@
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" /> <PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="KubernetesClient" Version="19.0.2" /> <PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageReference Include="Polly" Version="8.7.0" /> <PackageReference Include="Polly" Version="8.8.0" />
<PackageReference Include="Polly.Extensions" Version="8.7.0" /> <PackageReference Include="Polly.Extensions" Version="8.8.0" />
<Using Include="System.Security.Claims" /> <Using Include="System.Security.Claims" />
<Using Include="AccessTokenClient" /> <Using Include="AccessTokenClient" />
@@ -62,8 +63,8 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" /> <Using Include="Microsoft.OpenApi" />
@@ -73,17 +74,17 @@
<!-- Quartz Scheduler--> <!-- Quartz Scheduler-->
<ItemGroup> <ItemGroup>
<PackageReference Include="Hashids.net" Version="1.7.0" /> <PackageReference Include="Hashids.net" Version="1.7.0" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.203"> <PackageReference Include="Meziantou.Analyzer" Version="3.0.259">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.18.0" /> <PackageReference Include="OpenTelemetry" Version="1.18.0" />
<PackageReference Include="Quartz" Version="3.20.0" /> <PackageReference Include="Quartz" Version="4.1.0" />
<PackageReference Include="Quartz.Plugins" Version="3.20.0" /> <PackageReference Include="Quartz.Plugins" Version="4.1.0" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.20.0" /> <PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.20.0" /> <PackageReference Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.20.0" /> <PackageReference Include="Quartz.AspNetCore" Version="4.1.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.20.0" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Quartz" /> <Using Include="Quartz" />
@@ -91,11 +92,11 @@
<!-- Configuration --> <!-- Configuration -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
@@ -108,9 +109,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -136,18 +137,18 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Npgsql" /> <Using Include="Npgsql" />
@@ -159,8 +160,8 @@
<!-- Email --> <!-- Email -->
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="MimeKit" Version="4.17.0" /> <PackageReference Include="MimeKit" Version="4.18.0" />
<!-- Global Usings--> <!-- Global Usings-->
<Using Include="MimeKit" /> <Using Include="MimeKit" />
@@ -179,8 +180,8 @@
<!-- Amazon S3 SDK --> <!-- Amazon S3 SDK -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.1" /> <PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.103.3" />
<!-- global Usings --> <!-- global Usings -->
<Using Include="Amazon.S3" /> <Using Include="Amazon.S3" />
@@ -26,7 +26,7 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
.StartNow() .StartNow()
.Build(); .Build();
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken); await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), new ScheduleJobOptions { Replace = true }, cancellationToken);
} }
public async ValueTask ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default) public async ValueTask ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
@@ -51,17 +51,16 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
var trigger = global::Quartz.TriggerBuilder.Create() var trigger = global::Quartz.TriggerBuilder.Create()
.WithIdentity(triggerKey) .WithIdentity(triggerKey)
.WithDescription($"Scheduled via Main Job at {now:g}") .WithDescription($"Scheduled via Main Job at {now:g}")
.WithCronSchedule(cronExpression, cron => cron .WithCronSchedule(cronExpression, cron => cron.WithMisfireInstruction(CronTriggerMisfireInstruction.IgnoreMisfires))
.WithMisfireHandlingInstructionIgnoreMisfires())
.StartAt((DateTimeOffset)now) .StartAt((DateTimeOffset)now)
.Build(); .Build();
await scheduler.AddJob(job, replace: true, cancellationToken); await scheduler.AddJob(job, new AddJobOptions { Replace = true }, cancellationToken);
if (await scheduler.CheckExists(triggerKey, cancellationToken)) if (await scheduler.Exists(triggerKey, cancellationToken))
await scheduler.RescheduleJob(triggerKey, trigger, cancellationToken); await scheduler.RescheduleJob(triggerKey, trigger, cancellationToken);
else else
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken); await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), new ScheduleJobOptions { Replace = true }, cancellationToken);
} }
public async ValueTask<bool> InterruptAsync(string eventName, string? correlationId = null, CancellationToken cancellationToken = default) public async ValueTask<bool> InterruptAsync(string eventName, string? correlationId = null, CancellationToken cancellationToken = default)
@@ -82,6 +81,6 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
jobGroup = "scheduled-jobs"; jobGroup = "scheduled-jobs";
} }
return await scheduler.Interrupt(JobKey.Create(jobKeyName, jobGroup), cancellationToken); return await scheduler.Interrupt(new JobKey(jobKeyName, jobGroup), cancellationToken);
} }
} }
@@ -7,7 +7,8 @@ namespace PostFundManagement.Domain.Quartz;
[DisallowConcurrentExecution] [DisallowConcurrentExecution]
public sealed class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent public sealed class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent
{ {
public async Task Execute(IJobExecutionContext context)
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{ {
if (context.Recovering) if (context.Recovering)
Trace.WriteLine($"CRITICAL RECOVERY: Resurrecting job '{typeof(TNotification).Name}' after a previous cluster node crashed mid-execution."); Trace.WriteLine($"CRITICAL RECOVERY: Resurrecting job '{typeof(TNotification).Name}' after a previous cluster node crashed mid-execution.");
@@ -16,6 +16,23 @@ public sealed class RetryJobListener : IJobListener
return; return;
if (jobException is not null && context.RefireCount < RetryCount) if (jobException is not null && context.RefireCount < RetryCount)
jobException.RefireImmediately = true; {
if (context.JobDetail is null)
return;
var triggers = await context.Scheduler.GetTriggersOfJob(context.JobDetail.Key, cancellationToken);
var oldTrigger = triggers?.FirstOrDefault();
if (oldTrigger is null)
return;
var newTrigger = global::Quartz.TriggerBuilder.Create()
.WithIdentity($"{oldTrigger.Key.Name}-retry-{context.RefireCount}", oldTrigger.Key.Group)
.StartNow()
.ForJob(context.JobDetail.Key)
.Build();
await context.Scheduler.RescheduleJob(oldTrigger.Key, newTrigger, cancellationToken);
}
} }
} }
@@ -12,15 +12,15 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@@ -32,9 +32,9 @@
<!-- Extensions --> <!-- Extensions -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Microsoft.Extensions.DependencyInjection" /> <Using Include="Microsoft.Extensions.DependencyInjection" />
+13 -5
View File
@@ -9,6 +9,10 @@ services:
ports: ports:
- "8075:8080" - "8075:8080"
environment: environment:
# Service layer caching feature flag
- EnableServiceCaching=false
# Header forwarding overrides
- ASPNETCORE_ENVIRONMENT=${ENVIRONMENT:-Development} - ASPNETCORE_ENVIRONMENT=${ENVIRONMENT:-Development}
- ForwardedHeadersConfiguration__Enabled=true - ForwardedHeadersConfiguration__Enabled=true
- ForwardedHeadersConfiguration__AllowAll=true - ForwardedHeadersConfiguration__AllowAll=true
@@ -21,6 +25,7 @@ services:
# Postgres Database Overrides # Postgres Database Overrides
- ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD}; - ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD};
- ConnectionStrings__PfmSchedulerDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=pfm-scheduler;User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD};
# Garage S3 Settings # Garage S3 Settings
- GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za} - GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za}
@@ -69,10 +74,13 @@ services:
- "8076:3000" - "8076:3000"
environment: environment:
# OIDC Identity Provider Configuration # OIDC Identity Provider Configuration
- OIDC_AUTHORITY=${OIDC_AUTHORITY:-https://} - VITE_OIDC_AUTHORITY=${OIDC_AUTHORITY:-https://}
- OIDC_AUDIENCE=${OIDC_AUDIENCE:-aud} - VITE_OIDC_AUDIENCE=${OIDC_AUDIENCE:-aud}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-pfm-console-dev} - VITE_OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-pfm-console-dev}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-yoursecret} - VITE_OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-yoursecret}
- VITE_HASHER_MIN_LENGTH=${HASHER_MIN_LENGTH:-11}
- VITE_HASHER_SALT=${HASHER_SALT}
# Backend API Configuration # Backend API Configuration
- API_BASE_URL=${API_BASE_URL:-https://api.ifri.khongisa.co.za/} - VITE_API_BASE_URL=${API_BASE_URL:-https://api.ifri.khongisa.co.za/}
restart: unless-stopped restart: unless-stopped