Merge pull request 'Garages3' (#83) from garages3 into main

Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-09-18 16:12:06 +02:00
45 changed files with 4189 additions and 633 deletions
@@ -25,8 +25,8 @@
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
@@ -60,8 +60,8 @@
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
+5 -3
View File
@@ -1,12 +1,14 @@
using Microsoft.FeatureManagement;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Filters;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator;
using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFeatureManagement();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddApiVersioning(options =>
{
@@ -120,9 +122,9 @@ builder.Services.AddCors(options =>
var app = builder.Build();
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();
app.UseHsts();
+1
View File
@@ -1,4 +1,5 @@
{
"EnableServiceCaching": true,
"CorsOrigins": [
"https://console.ifri.khongisa.co.za",
"https://localhost:7208"
@@ -2,7 +2,7 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Quartz;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Domain.Extensions;
namespace PostFundManagement.Application.Extensions;
public static class Quartz
{
@@ -10,35 +10,33 @@ public static class Quartz
{
var connectionString = configuration.GetConnectionString(DatabaseConfigName);
services.Configure<QuartzOptions>(options =>
{
options.Properties["quartz.scheduler.instanceName"] = schedulerName;
options.Properties["quartz.scheduler.instanceId"] = "AUTO";
});
services.ConfigureCommon();
services.AddQuartz(config =>
{
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
config.UseTimeZoneConverter();
config.UsePersistentStore(storage =>
{
storage.PerformSchemaValidation = false;
storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster =>
{
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90);
});
});
});
});
return services;
@@ -48,36 +46,37 @@ public static class Quartz
{
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.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.AddQuartz(config =>
{
config.SchedulerName = schedulerName;
config.SchedulerId = "AUTO";
config.InterruptJobsOnShutdown = true;
config.InterruptJobsOnShutdownWithWait = true;
config.MaxBatchSize = 5;
config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1);
config.UseDefaultThreadPool(options => options.MaxConcurrency = 5);
config.UseTimeZoneConverter();
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture));
config.UsePersistentStore(storage =>
{
storage.PerformSchemaValidation = false;
storage.ProvisionSchema();
storage.UsePostgres(options => options.ConnectionString = connectionString!);
storage.UseSystemTextJsonSerializer();
storage.SetProperty("quartz.jobStore.clustered", "true");
storage.UsePostgres(options =>
{
options.ConnectionString = connectionString!;
options.TablePrefix = "quartz.qrtz_";
});
storage.UseClustering(cluster =>
{
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
@@ -91,18 +90,10 @@ public static class Quartz
private static IServiceCollection ConfigureCommon(this IServiceCollection services)
{
services.Configure<QuartzOptions>(options =>
{
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.Configure<QuartzOptions>(options => options.Scheduling.IgnoreDuplicates = true);
services.AddTransient<RetryJobListener>();
services.AddTransient<IJobOrchestrator, JobOrchestrator>();
return services;
}
}
@@ -24,9 +24,9 @@
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
@@ -36,6 +36,7 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Humanizer" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.ComponentModel.DataAnnotations" />
<Using Include="Microsoft.Extensions.Caching.Distributed" />
<Using Include="Microsoft.Extensions.Caching.Hybrid" />
@@ -4,14 +4,19 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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) =>
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 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) =>
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 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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetContractUrlAsync(contractIdHash, cancellationToken);
if(result.IsFailed) return result;
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value, cancellationToken);
@@ -4,10 +4,12 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -84,11 +95,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Milestone[]>> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetMilestonesAsync(pagination, cancellationToken);
@@ -105,11 +119,14 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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) =>
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 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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -197,7 +226,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{
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);
@@ -210,15 +239,18 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if(string.IsNullOrWhiteSpace(cached))
if (string.IsNullOrWhiteSpace(cached))
{
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);
@@ -231,6 +263,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -239,7 +274,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{
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);
@@ -252,6 +287,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -260,7 +298,7 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
{
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);
@@ -1,14 +1,19 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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 cached = await cache.GetStringAsync(cacheKey, cancellationToken);
@@ -4,14 +4,19 @@ using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Models;
using static PostFundManagement.Domain.Extensions.Constants;
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) =>
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 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) =>
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}-" +
$"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value;
+4 -5
View File
@@ -10,14 +10,13 @@ APP_URL="MY_APP_URL"
# OIDC SecuritySettings
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
VITE_OIDC_AUDIENCE="change-me"
VITE_OIDC_CLIENT_ID="change-me"
VITE_OIDC_CLIENT_SECRET="change-me"
VITE_OIDC_AUDIENCE="pfm-api-dev"
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
# PFM Backend API Base URL
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="change-me"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
+8
View File
@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
+2 -2
View File
@@ -32,11 +32,11 @@
"zod": "^4.5.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tsx": "^4.21.0",
"typescript": "~5.8.2"
"typescript": "~5.8.2",
"@types/express": "^4.17.21"
}
}
@@ -4,6 +4,23 @@ import { Navigate, useLocation } from 'react-router-dom';
import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config';
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 {
onSessionExpired,
triggerSessionExpired,
clearSessionAndRedirectToLanding,
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
export interface AuthUserProfile {
id: string;
@@ -25,8 +42,14 @@ interface AuthContextType {
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
isSynced: boolean;
isPendingActivation: boolean;
activationError: string | null;
isSessionExpired: boolean;
triggerSessionExpired: (reason?: string) => void;
login: () => Promise<void>;
logout: () => Promise<void>;
syncUserSession: (force?: boolean) => Promise<UserSyncResult>;
authError: string | null;
}
@@ -98,9 +121,58 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [claims, setClaims] = useState<UserClaims>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
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 [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
// Initialize auth state and check for OIDC callback
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
// Subordination: If account activation lockout is active, session expiration is completely avoided and bypassed
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]);
// Priority Subordination: When activation process is active, dismiss all other modals,
// cancel session expiration and avoid its processes so that there is no overlapping.
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
setSessionExpiredReason(null);
cancelSessionExpiration();
dismissSubordinateModals();
}
}, [isPendingActivation]);
// When session expiration modal is displayed, dismiss all other subordinates
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(() => {
const initAuth = async () => {
try {
@@ -148,6 +220,38 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Execute user sync endpoint upon login.
// If HTTP 401 -> routes to landing page
// If non-401 failure -> displays non-dismissable activation modal
// If success -> saved in browser to prevent endpoint hotspot
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);
return;
}
@@ -181,6 +285,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub);
}
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
@@ -188,8 +293,48 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
parsedUser.tenant = parsedClaims.tenant;
}
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) {
console.warn('OIDC initialization notice:', err);
@@ -202,6 +347,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
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 () => {
try {
setAuthError(null);
@@ -211,7 +386,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
await mgr.signinRedirect();
} catch (err: unknown) {
console.warn('OIDC Redirect encountered:', err);
// In sandboxed preview iframe, navigation to external identity provider domain can be blocked by CSP/frame-ancestors.
if (typeof window !== 'undefined' && window.self !== window.top) {
const errorMsg =
'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.';
@@ -228,10 +402,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
clearUserSyncInBrowser();
setUser(null);
setSub('');
setClaims({});
setAccessToken(null);
setIsSynced(false);
setIsPendingActivation(false);
setActivationError(null);
try {
const mgr = getUserManager();
@@ -253,12 +431,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
accessToken,
isAuthenticated: !!accessToken,
isLoading,
isSynced,
isPendingActivation,
activationError,
isSessionExpired,
triggerSessionExpired,
login,
logout,
syncUserSession,
authError,
}}
>
{children}
<SessionExpiredModal
isOpen={isSessionExpired && !isPendingActivation}
reason={sessionExpiredReason}
onDismiss={handleSessionExpiredDismiss}
countdownSeconds={8}
/>
</AuthContext.Provider>
);
};
@@ -273,7 +463,17 @@ export const useAuth = () => {
// Route Guard
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();
if (isLoading) {
@@ -294,5 +494,20 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
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}</>;
};
@@ -0,0 +1,269 @@
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);
// Activation process dismisses all other modals so that it is displayed without overlapping.
// By extension, any active session expiration modal is dismissed and its processes cancelled.
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);
// When session expiration modal is displayed, dismiss all subordinate modals
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
// Listen for cancellation event (e.g., when activation lockout takes priority)
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>
);
};
@@ -18,6 +18,9 @@ import {
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
const navItems = [
{
@@ -62,6 +65,18 @@ export const AppLayout: React.FC = () => {
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
if (sub && (user?.email || claims.email)) {
registerUserEmail(sub, ((user?.email || claims.email) as string));
}
// Extract name and email directly from validated token claims
const tokenUserName =
user?.name ||
@@ -75,7 +90,7 @@ export const AppLayout: React.FC = () => {
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims.upn as string) ||
(sub ? `sub:${sub}` : '');
(sub ? getUserEmail(sub) : '');
// Extract tenant name directly from the user claim named 'tenant'
const tenantName =
@@ -252,8 +267,7 @@ export const AppLayout: React.FC = () => {
>
<div className="p-2.5 border-b border-slate-800">
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p>
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
<p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
</div>
<Link
@@ -22,13 +22,19 @@ export const Modal: React.FC<ModalProps> = ({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleDismissSubordinates = () => {
onClose();
};
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
}
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
};
}, [isOpen, onClose]);
@@ -0,0 +1,588 @@
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';
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>
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
{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>
</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,230 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from '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';
interface ToastItem {
export interface ToastItem {
id: string;
type: ToastType;
title: string;
message?: string;
duration: number; // in milliseconds
createdAt: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}
export interface ToastOptions {
duration?: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}
interface ToastContextType {
toast: (type: ToastType, title: string, message?: string) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void;
success: (title: string, message?: string, options?: ToastOptions) => void;
error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void;
info: (title: string, message?: string, options?: ToastOptions) => void;
}
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>
{toast.message && (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
</p>
)}
{/* 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 }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
@@ -26,45 +232,89 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
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);
setToasts((prev) => [...prev, { id, type, title, message }]);
setTimeout(() => removeToast(id), 5000);
const duration = options?.duration || DEFAULT_DURATIONS[type];
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,
},
]);
};
const success = (title: string, message?: string) => addToast('success', title, message);
const error = (title: string, message?: string) => addToast('error', title, message);
const info = (title: string, message?: string) => addToast('info', title, message);
const success = (title: string, message?: string, options?: ToastOptions) =>
addToast('success', title, message, options);
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,
});
};
return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{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">
<div
role="region"
aria-label="System Notifications"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
>
{toasts.map((t) => (
<div
key={t.id}
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'
? 'border-green-500/30 text-gray-200'
: 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" />}
{t.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{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>
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider>
@@ -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);
switch (s) {
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',
};
case ActivityStatus.Disabled:
default:
return {
label: 'Disabled',
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) => {
const p = Number(priority);
switch (p) {
@@ -419,14 +494,17 @@ export const getNotificationStatusBadge = (status?: NotificationStatus | 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) {
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) {
return { level: 'Medium', color: 'bg-amber-500 text-white', badge: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
if (score >= 10) {
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' };
};
// ==========================================
@@ -435,15 +513,30 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R
const USER_EMAIL_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string, email: string) => {
if (userId && email) {
USER_EMAIL_CACHE[userId] = email;
export const registerUserEmail = (userId: string | number, email: string) => {
if (userId && email && typeof email === 'string' && email.trim()) {
const cleanId = String(userId).trim();
const cleanEmail = email.trim();
USER_EMAIL_CACHE[cleanId] = cleanEmail;
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
}
};
export const getUserEmail = (userIdOrEmail?: string | null): string => {
if (!userIdOrEmail) return '—';
if (userIdOrEmail.includes('@')) return userIdOrEmail;
if (USER_EMAIL_CACHE[userIdOrEmail]) return USER_EMAIL_CACHE[userIdOrEmail];
return userIdOrEmail;
export const registerUserEmails = (users: Array<{ id: string | number; email?: string | null }>) => {
if (!Array.isArray(users)) return;
for (const u of users) {
if (u && u.id && u.email) {
registerUserEmail(u.id, u.email);
}
}
};
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()];
return str;
};
@@ -1,5 +1,5 @@
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 {
TrendingUp,
@@ -23,11 +23,14 @@ import {
getBudgets,
getDisbursements,
getAllRisks,
getAllIndicators,
} from '../services/api';
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
import { ApprovalStatus } from '../types';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
export const ExecutiveDashboard: React.FC = () => {
const queryClient = useQueryClient();
const [selectedProgramme, setSelectedProgramme] = useState('All');
const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
@@ -73,61 +76,140 @@ export const ExecutiveDashboard: React.FC = () => {
queryFn: () => getAllRisks(),
});
const { data: indicators = [] } = useQuery({
queryKey: ['indicators'],
queryFn: () => getAllIndicators(),
});
const refetchAll = () => {
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 metrics sourced directly from live API models with real filtering
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) {
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;
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;
}, [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(() => {
if (dashboard?.disbursed && dashboard.disbursed > 0) {
return dashboard.disbursed;
}
return disbursements
const relevantDisbursements = disbursements.filter((d) => {
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
return projectIds.has(String(d.projectId));
}
return true;
});
const approvedTotal = relevantDisbursements
.filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3)
.reduce((sum, d) => sum + (d.amount || 0), 0);
}, [dashboard, disbursements]);
.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
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(() => {
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;
}
if (projects.length === 0) return 0;
const activeOnTrack = projects.filter((p) => p.status === ApprovalStatus.Approved).length;
return Math.round((activeOnTrack / projects.length) * 100);
}, [dashboard, projects]);
return Math.round((onTrack / targetProjects.length) * 100);
}, [dashboard, filteredProjects, projects]);
// Dynamically calculate At Risk count directly from live registered risks
const atRiskCount = useMemo(() => {
if (dashboard?.atRiskCount !== undefined) {
return dashboard.atRiskCount;
}
return risks.filter((r) => r.likelihood * r.impact >= 12).length;
}, [dashboard, risks]);
const targetRisks = filteredRisks;
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);
});
const beneficiariesReached = dashboard?.beneficiariesReached ?? 0;
if (targetRisks.length > 0) {
return highRisks.length;
}
return dashboard?.atRiskCount ?? 0;
}, [dashboard, filteredRisks]);
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
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> = {};
projects.forEach((p) => {
targetProjects.forEach((p) => {
const label = getIndustrySectorLabel(p.sector);
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 totalProjects = projects.length;
const totalProjects = targetProjects.length;
return Object.entries(counts).map(([name, count], index) => {
const percentage = Math.round((count / totalProjects) * 100);
@@ -140,27 +222,7 @@ export const ExecutiveDashboard: React.FC = () => {
color: colors[index % colors.length],
};
});
}, [projects, portfolioValue]);
// Risk heatmap matrix computed directly from registered risk records
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]);
}, [projects, filteredProjects, portfolioValue]);
// Quarterly disbursements distribution computed from real disbursement items
const quarterlyTrend = useMemo(() => {
@@ -198,26 +260,59 @@ export const ExecutiveDashboard: React.FC = () => {
}));
}, [disbursements]);
// Attention Projects derived directly from live project data
// Attention Projects derived directly from live project data & risk scores
const attentionProjects = useMemo(() => {
if (!projects || projects.length === 0) return [];
return projects
.filter((p) => p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review)
.slice(0, 5)
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return [];
// 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) => {
const maxScore = projectMaxRisk[String(p.id)] || 0;
const isRejected = p.status === ApprovalStatus.Rejected;
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 {
id: p.id,
name: p.name,
programme: p.programmeName || `Programme ${p.programmeId}`,
status: isRejected ? 'At Risk' : isPending ? 'Pending' : 'Active',
risk: isRejected ? 'RED' : isPending ? 'AMBER' : 'GREEN',
financialVariance: isRejected ? '-15%' : '0%',
varianceType: isRejected ? 'negative' : 'positive',
status: statusLabel,
risk: riskLevel,
financialVariance: variance,
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 (
<div className="space-y-6 max-w-7xl mx-auto">
@@ -384,15 +479,15 @@ export const ExecutiveDashboard: React.FC = () => {
</div>
</div>
{/* Visual Models Section (Sector, Heatmap, Impact Trend) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Visual Models Section (Sector Breakdown & Disbursements) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 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>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
<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>
</div>
@@ -436,75 +531,7 @@ export const ExecutiveDashboard: React.FC = () => {
</div>
</div>
{/* Panel 2: Portfolio Risk Heatmap */}
<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 */}
{/* 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">
@@ -558,6 +585,14 @@ export const ExecutiveDashboard: React.FC = () => {
</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 */}
<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">
@@ -15,6 +15,7 @@ import {
Search,
Coins,
Eye,
RefreshCw,
} from 'lucide-react';
import {
getOrganisations,
@@ -49,6 +50,7 @@ import {
import {
getOrganisationTypeLabel,
getActivityStatusBadge,
getOrganisationStatusBadge,
getApprovalStatusBadge,
formatDate,
getUserEmail,
@@ -192,7 +194,7 @@ export const GovernancePage: React.FC = () => {
orgForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save organisation', String(err));
toastError('Failed to save organisation', err);
},
});
@@ -217,7 +219,7 @@ export const GovernancePage: React.FC = () => {
portfolioForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save portfolio', String(err));
toastError('Failed to save portfolio', err);
},
});
@@ -242,7 +244,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save programme', String(err));
toastError('Failed to save programme', err);
},
});
@@ -409,17 +411,31 @@ export const GovernancePage: React.FC = () => {
</button>
</div>
{/* Search Bar */}
{/* Search Bar & Actions */}
{activeTab === 'organisations' && (
<div className="relative max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by organisation name, reg no, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by organisation name, reg no, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
<button
type="button"
onClick={() => {
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success('Refreshed', 'Organisation directory refreshed from database.');
}}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Force refresh organisation directory from database"
>
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
Refresh Directory
</button>
</div>
)}
@@ -453,7 +469,7 @@ export const GovernancePage: React.FC = () => {
</tr>
) : (
filteredOrgs.map((org) => {
const statusBadge = getActivityStatusBadge(org.status);
const statusBadge = getOrganisationStatusBadge(org);
return (
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
@@ -21,6 +21,7 @@ import {
Download,
Upload,
Link as LinkIcon,
Edit2,
} from 'lucide-react';
import {
getAwards,
@@ -39,6 +40,7 @@ import {
getDisbursements,
getDisbursement,
createDisbursement,
updateDisbursement,
approveDisbursement,
getOrganisations,
getAllProgrammes,
@@ -122,6 +124,7 @@ export const GrantsPage: React.FC = () => {
const [selectedContractForExecution, setSelectedContractForExecution] = useState<Contract | null>(null);
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
// Queries
const { data: awards = [], isLoading: loadingAwards } = useQuery({
@@ -227,7 +230,7 @@ export const GrantsPage: React.FC = () => {
awardForm.reset();
},
onError: (err: unknown) => {
toastError('Award registration failed', String(err));
toastError('Award registration failed', err);
},
});
@@ -240,7 +243,7 @@ export const GrantsPage: React.FC = () => {
contractForm.reset();
},
onError: (err: unknown) => {
toastError('Contract creation failed', String(err));
toastError('Contract creation failed', err);
},
});
@@ -256,7 +259,7 @@ export const GrantsPage: React.FC = () => {
executeForm.reset();
},
onError: (err: unknown) => {
toastError('Execution trigger failed', String(err));
toastError('Execution trigger failed', err);
},
});
@@ -269,23 +272,60 @@ export const GrantsPage: React.FC = () => {
budgetForm.reset();
},
onError: (err: unknown) => {
toastError('Budget creation failed', String(err));
toastError('Budget creation failed', err);
},
});
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: () => {
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);
setEditingDisbursement(null);
disbursementForm.reset();
},
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({
mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => {
@@ -297,7 +337,7 @@ export const GrantsPage: React.FC = () => {
);
},
onError: (err: unknown) => {
toastError('Disbursement approval failed', String(err));
toastError('Disbursement approval failed', err);
},
});
@@ -321,7 +361,7 @@ export const GrantsPage: React.FC = () => {
toastError('Download Error', 'No active secure download link generated.');
}
} catch (err: any) {
toastError('Link Error', err?.message || 'Failed to fetch contract secure link.');
toastError('Link Error', err);
}
};
@@ -331,7 +371,7 @@ export const GrantsPage: React.FC = () => {
success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
queryClient.invalidateQueries({ queryKey: ['contracts'] });
} catch (err: any) {
toastError('Upload Failed', err?.message || 'Contract file upload failed.');
toastError('Upload Failed', err);
}
};
@@ -341,7 +381,7 @@ export const GrantsPage: React.FC = () => {
success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
queryClient.invalidateQueries({ queryKey: ['contracts'] });
} catch (err: any) {
toastError('Update Failed', err?.message || 'Failed to update contract status.');
toastError('Update Failed', err);
}
};
@@ -398,7 +438,7 @@ export const GrantsPage: React.FC = () => {
)}
{activeTab === 'disbursements' && (
<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"
>
<Plus className="w-4 h-4" />
@@ -735,21 +775,33 @@ export const GrantsPage: React.FC = () => {
</span>
</td>
<td className="py-3.5 px-5 text-right">
{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">
<CheckCircle className="w-3.5 h-3.5" />
Released
</span>
) : (
<button
onClick={() => approveMutation.mutate(d.id)}
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"
>
<CheckCircle className="w-3.5 h-3.5" />
Approve Payment
</button>
)}
<div className="flex items-center justify-end gap-1.5">
{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">
<CheckCircle className="w-3.5 h-3.5" />
Released
</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
onClick={() => approveMutation.mutate(d.id)}
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 cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
Approve Payment
</button>
</>
)}
</div>
</td>
</tr>
);
@@ -1052,27 +1104,36 @@ export const GrantsPage: React.FC = () => {
</form>
</Modal>
{/* Modal: Schedule Disbursement */}
{/* Modal: Schedule or Edit Disbursement */}
<Modal
isOpen={isDisbursementModalOpen}
onClose={() => setIsDisbursementModalOpen(false)}
title="Schedule Disbursement Tranche"
subtitle="Link disbursement to milestone verification and conditions precedent"
onClose={() => {
setIsDisbursementModalOpen(false);
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'
}
>
<form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
<select
{...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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{!editingDisbursement && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
<select
{...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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Linked Milestone Deliverable</label>
@@ -1101,7 +1162,10 @@ export const GrantsPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<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"
>
Cancel
@@ -1111,7 +1175,11 @@ export const GrantsPage: React.FC = () => {
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"
>
{disbursementMutation.isPending ? 'Scheduling...' : 'Schedule Disbursement'}
{disbursementMutation.isPending
? 'Saving...'
: editingDisbursement
? 'Update Disbursement'
: 'Schedule Disbursement'}
</button>
</div>
</form>
@@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider';
import { BrandLogo } from '../components/ui/BrandLogo';
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 handleEnterConsole = async () => {
@@ -70,9 +70,13 @@ export const LandingPage: React.FC = () => {
<button
onClick={() => navigate('/dashboard')}
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" />
</button>
) : (
@@ -191,19 +195,36 @@ export const LandingPage: React.FC = () => {
</div>
{/* Guidance Message */}
<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>
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>
{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">
<p className="font-semibold text-white mb-1">Session Verified</p>
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>
)}
{/* Navigation and Sign Out Buttons */}
<div className="space-y-3 pt-1">
<button
onClick={() => navigate('/dashboard')}
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" />
</button>
@@ -222,9 +243,9 @@ export const LandingPage: React.FC = () => {
<KeyRound className="w-3.5 h-3.5 text-blue-400" />
<span>OAuth2 / OpenID Connect</span>
</span>
<span className="text-emerald-400 font-medium text-[11px] flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
Verified Session
<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>
</div>
</div>
@@ -233,9 +254,9 @@ export const LandingPage: React.FC = () => {
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
<div className="text-center space-y-3 pt-2">
<div className="flex justify-center">
<BrandLogo size={48} showGlow animateOnHover={false} />
<div className="text-center space-y-2 pt-2">
<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" />
</div>
<div>
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
@@ -275,12 +296,8 @@ export const LandingPage: React.FC = () => {
</main>
{/* Footer */}
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-500">
<div className="flex items-center gap-2.5">
<BrandLogo size={20} showGlow={false} animateOnHover={false} />
<span className="text-slate-400 font-medium">Post-Fund Management, Performance & Impact System</span>
</div>
<p className="text-slate-500">Enterprise Governance & Audit Console</p>
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 text-center text-xs text-slate-500">
<p>Post-Fund Management, Performance & Impact System &bull; Enterprise Governance Console</p>
</footer>
</div>
);
@@ -19,6 +19,7 @@ import {
Check,
Filter,
Link as LinkIcon,
Edit2,
} from 'lucide-react';
import {
getAllIndicators,
@@ -31,11 +32,13 @@ import {
updateEvidence,
getAllRisks,
createRisk,
updateRisk,
getProjects,
getAllMilestones,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import {
Indicator,
Evidence,
@@ -90,6 +93,7 @@ export const MonitoringPage: React.FC = () => {
// Modals & States
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
const [isRiskModalOpen, setIsRiskModalOpen] = useState(false);
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
@@ -160,7 +164,7 @@ export const MonitoringPage: React.FC = () => {
indicatorForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to create indicator', String(err));
toastError('Failed to create indicator', err);
},
});
@@ -173,7 +177,7 @@ export const MonitoringPage: React.FC = () => {
setSelectedIndicatorForActual(null);
},
onError: (err: unknown) => {
toastError('Failed to record actual', String(err));
toastError('Failed to record actual', err);
},
});
@@ -184,7 +188,7 @@ export const MonitoringPage: React.FC = () => {
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)');
},
onError: (err: unknown) => {
toastError('Upload failed', String(err));
toastError('Upload failed', err);
},
});
@@ -209,21 +213,66 @@ export const MonitoringPage: React.FC = () => {
setReviewerComment('');
},
onError: (err: unknown) => {
toastError('Review update failed', String(err));
toastError('Review update failed', err);
},
});
const openCreateRisk = () => {
setEditingRisk(null);
riskForm.reset({
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
description: '',
likelihood: RiskLikelihood.Medium,
impact: RiskImpact.Moderate,
});
setIsRiskModalOpen(true);
};
const openEditRisk = (rk: Risk) => {
setEditingRisk(rk);
riskForm.reset({
projectId: Number(rk.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
name: rk.name || '',
description: rk.description || '',
likelihood: (rk.likelihood as RiskLikelihood) ?? RiskLikelihood.Medium,
impact: (rk.impact as RiskImpact) ?? RiskImpact.Moderate,
});
setIsRiskModalOpen(true);
};
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: () => {
queryClient.invalidateQueries({ queryKey: ['risks'] });
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);
setEditingRisk(null);
riskForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to log risk', String(err));
toastError(editingRisk ? 'Failed to update risk' : 'Failed to log risk', err);
},
});
@@ -249,7 +298,7 @@ export const MonitoringPage: React.FC = () => {
toastError('Download Error', 'No active secure download link generated.');
}
} catch (err: any) {
toastError('Link Error', err?.message || 'Failed to retrieve evidence secure link.');
toastError('Link Error', err);
}
};
@@ -288,7 +337,7 @@ export const MonitoringPage: React.FC = () => {
)}
{activeTab === 'risks' && (
<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"
>
<Plus className="w-4 h-4" />
@@ -614,57 +663,98 @@ export const MonitoringPage: React.FC = () => {
</div>
)}
{/* Tab 3: Risk Register (M09) */}
{/* Tab 3: Risk Register & Heatmap (M09) */}
{activeTab === 'risks' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Risk Title & Description</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
<th className="py-3.5 px-4 text-center">Impact (1–5)</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-5">Mitigation Control</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingRisks ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading risk register...
</td>
<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="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">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Risk Title & Description</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
<th className="py-3.5 px-4 text-center">Impact (1–5)</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-5">Mitigation Control</th>
<th className="py-3.5 px-4 text-right">Actions</th>
</tr>
) : (
risks.map((rk) => {
const score = rk.likelihood * rk.impact;
const color = getRiskScoreColor(rk.likelihood, rk.impact);
return (
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{rk.name}</span>
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
<td className="py-3.5 px-4 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
{score} / 25 ({color.level})
</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
{rk.mitigationAction || 'Standard internal audit controls applied.'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingRisks ? (
<tr>
<td colSpan={8} className="py-8 text-center text-slate-500">
Loading risk register...
</td>
</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) => {
const score = rk.likelihood * rk.impact;
const color = getRiskScoreColor(rk.likelihood, rk.impact);
return (
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{rk.name}</span>
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
<td className="py-3.5 px-4 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
{score} / 25 ({color.level})
</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
{rk.mitigationAction || 'Standard internal audit controls applied.'}
</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>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
@@ -803,27 +893,32 @@ export const MonitoringPage: React.FC = () => {
</div>
</Modal>
{/* Modal: Add Risk */}
{/* Modal: Add or Edit Risk */}
<Modal
isOpen={isRiskModalOpen}
onClose={() => setIsRiskModalOpen(false)}
title="Add Risk to Risk Register"
subtitle="Identify and score project delivery risks"
onClose={() => {
setIsRiskModalOpen(false);
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'}
>
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
<select
{...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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{!editingRisk && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
<select
{...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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label>
@@ -877,7 +972,10 @@ export const MonitoringPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<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"
>
Cancel
@@ -887,7 +985,7 @@ export const MonitoringPage: React.FC = () => {
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"
>
{riskMutation.isPending ? 'Logging...' : 'Log Risk'}
{riskMutation.isPending ? 'Saving...' : editingRisk ? 'Update Risk' : 'Log Risk'}
</button>
</div>
</form>
@@ -17,15 +17,19 @@ import {
FileCheck,
TrendingUp,
X,
Edit2,
} from 'lucide-react';
import {
getProjects,
createProject,
updateProject,
updateProjectStatus,
getActivities,
createActivity,
updateActivity,
getMilestones,
createMilestone,
updateMilestone,
getAllProgrammes,
getProgrammeProjects,
getActivity,
@@ -37,6 +41,8 @@ import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import {
Project,
Activity,
Milestone,
IndustrySector,
ProjectType,
ApprovalStatus,
@@ -44,6 +50,8 @@ import {
CreateProjectRequest,
CreateActivityRequest,
CreateMilestoneRequest,
UpdateActivityRequest,
UpdateMilestoneRequest,
} from '../types';
import {
getIndustrySectorLabel,
@@ -52,6 +60,7 @@ import {
getPriorityBadge,
formatDate,
formatZAR,
getUserEmail,
INDUSTRY_SECTOR_OPTIONS,
PROJECT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
@@ -71,12 +80,14 @@ const activitySchema = z.object({
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
milestoneId: z.string().optional().nullable(),
});
const milestoneSchema = z.object({
name: z.string().min(4, 'Milestone deliverable required'),
dueAt: z.string().min(4, 'Due date is mandatory'),
priority: z.coerce.number().min(1).max(4),
status: z.coerce.number().optional(),
});
type ProjectFormValues = z.infer<typeof projectSchema>;
@@ -91,10 +102,15 @@ export const ProjectsPage: React.FC = () => {
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
// Modals
// Modals & Editing State
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
const [editingActivity, setEditingActivity] = useState<Activity | null>(null);
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
// Queries
const { data: programmes = [] } = useQuery({
@@ -156,6 +172,7 @@ export const ProjectsPage: React.FC = () => {
description: '',
category: '',
amount: 0,
milestoneId: '',
},
});
@@ -165,21 +182,108 @@ export const ProjectsPage: React.FC = () => {
name: '',
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
priority: Priority.High,
status: ApprovalStatus.Pending,
},
});
// Open Handlers
const openCreateProject = () => {
setEditingProject(null);
projectForm.reset({
name: '',
description: '',
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
});
setIsProjectModalOpen(true);
};
const openEditProject = (p: Project) => {
setEditingProject(p);
projectForm.reset({
name: p.name || '',
description: p.description || '',
programmeId: p.programmeId ? Number(p.programmeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
sector: p.sector || IndustrySector.AgriTech,
projectType: p.projectType || ProjectType.Innovation,
});
setIsProjectModalOpen(true);
};
const openCreateActivity = () => {
setEditingActivity(null);
activityForm.reset({
title: '',
description: '',
category: '',
amount: 0,
milestoneId: '',
});
setIsActivityModalOpen(true);
};
const openEditActivity = (a: Activity) => {
setEditingActivity(a);
activityForm.reset({
title: a.title || '',
description: a.description || '',
category: a.category || '',
amount: a.amount || 0,
milestoneId: a.milestoneId ? String(a.milestoneId) : '',
});
setIsActivityModalOpen(true);
};
const openCreateMilestone = () => {
setEditingMilestone(null);
milestoneForm.reset({
name: '',
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
priority: Priority.High,
status: ApprovalStatus.Pending,
});
setIsMilestoneModalOpen(true);
};
const openEditMilestone = (m: Milestone) => {
setEditingMilestone(m);
milestoneForm.reset({
name: m.name || '',
dueAt: m.dueAt ? m.dueAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
priority: m.priority || Priority.High,
status: m.status || ApprovalStatus.Pending,
});
setIsMilestoneModalOpen(true);
};
// Mutations
const projectMutation = useMutation({
mutationFn: (data: CreateProjectRequest) => createProject(data),
onSuccess: (newProj) => {
mutationFn: (data: ProjectFormValues) => {
if (editingProject) {
return updateProject({
projectId: editingProject.id,
name: data.name,
description: data.description,
sector: Number(data.sector),
projectType: Number(data.projectType),
});
}
return createProject(data as CreateProjectRequest);
},
onSuccess: (savedProj) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success('Project registered', `Project "${newProj.name}" created successfully`);
success(
editingProject ? 'Project updated' : 'Project registered',
`Project "${savedProj.name}" saved successfully`
);
setIsProjectModalOpen(false);
setEditingProject(null);
projectForm.reset();
setSelectedProject(newProj);
setSelectedProject(savedProj);
},
onError: (err: unknown) => {
toastError('Failed to create project', String(err));
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
},
});
@@ -194,33 +298,88 @@ export const ProjectsPage: React.FC = () => {
success('Status updated', 'Project governance status changed successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', String(err));
toastError('Failed to update status', err);
},
});
const activityMutation = useMutation({
mutationFn: (data: CreateActivityRequest) => createActivity(data),
mutationFn: (data: ActivityFormValues) => {
const parsedMilestoneId =
data.milestoneId && String(data.milestoneId).trim() !== ''
? Number(data.milestoneId)
: null;
if (editingActivity) {
return updateActivity({
activityId: editingActivity.id,
projectId: activeProjectId!,
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
milestoneId: parsedMilestoneId,
});
}
return createActivity({
projectId: activeProjectId!,
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
milestoneId: parsedMilestoneId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
success('Activity added', 'Activity registered to project work breakdown');
success(
editingActivity ? 'Activity updated' : 'Activity added',
editingActivity
? 'Activity updated in project work breakdown'
: 'Activity registered to project work breakdown'
);
setIsActivityModalOpen(false);
setEditingActivity(null);
activityForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to add activity', String(err));
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
},
});
const milestoneMutation = useMutation({
mutationFn: (data: CreateMilestoneRequest) => createMilestone(data),
mutationFn: async (data: MilestoneFormValues): Promise<void> => {
if (editingMilestone) {
await updateMilestone({
milestoneId: editingMilestone.id,
name: data.name,
dueAt: data.dueAt,
priority: Number(data.priority),
status: Number(data.status ?? editingMilestone.status),
});
} else {
await createMilestone({
projectId: activeProjectId!,
name: data.name,
dueAt: data.dueAt,
priority: Number(data.priority),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
success('Milestone created', 'Contractual deliverable added to implementation schedule');
queryClient.invalidateQueries({ queryKey: ['projects'] });
success(
editingMilestone ? 'Milestone updated' : 'Milestone created',
editingMilestone
? 'Contractual deliverable updated successfully'
: 'Contractual deliverable added to implementation schedule'
);
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
milestoneForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to add milestone', String(err));
toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
},
});
@@ -247,7 +406,7 @@ export const ProjectsPage: React.FC = () => {
</div>
<button
onClick={() => setIsProjectModalOpen(true)}
onClick={openCreateProject}
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-md shadow-blue-500/20 transition cursor-pointer self-start sm:self-auto"
>
<Plus className="w-4 h-4" />
@@ -331,27 +490,47 @@ export const ProjectsPage: React.FC = () => {
</span>
<h3 className="text-lg font-bold text-white mt-1.5 leading-snug">{selectedProject.name}</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{selectedProject.description}</p>
<div className="flex flex-wrap items-center gap-3 mt-2.5 text-[11px] text-slate-400 font-medium">
<span>
Created by: <strong className="text-slate-200 font-normal">{getUserEmail(selectedProject.createdBy)}</strong>
</span>
<span>&bull;</span>
<span>
Sector: <strong className="text-slate-200 font-normal">{getIndustrySectorLabel(selectedProject.sector)}</strong>
</span>
</div>
</div>
{/* Status Change Selector */}
<div className="shrink-0 text-right">
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
<select
value={selectedProject.status}
onChange={(e) =>
statusMutation.mutate({
projectId: selectedProject.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
{/* Actions & Status Change Selector */}
<div className="shrink-0 text-right space-y-2">
<button
type="button"
onClick={() => openEditProject(selectedProject)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
<select
value={selectedProject.status}
onChange={(e) =>
statusMutation.mutate({
projectId: selectedProject.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
@@ -433,7 +612,7 @@ export const ProjectsPage: React.FC = () => {
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<button
onClick={() => setIsActivityModalOpen(true)}
onClick={openCreateActivity}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
@@ -445,19 +624,44 @@ export const ProjectsPage: React.FC = () => {
<p className="text-xs text-slate-500 py-4 text-center">No activities recorded yet.</p>
) : (
<div className="space-y-2.5">
{activities.map((a) => (
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between">
<span className="font-semibold text-xs text-white">{a.title}</span>
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
{activities.map((a) => {
const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null;
return (
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<span className="font-semibold text-xs text-white block">{a.title}</span>
{a.milestoneId ? (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
<Flag className="w-2.5 h-2.5" />
Milestone #{a.milestoneId}
{linkedMilestone ? ` - ${linkedMilestone.name}` : ''}
</span>
) : (
<span className="text-[10px] text-slate-500 italic">Independent Activity</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
<button
onClick={() => openEditActivity(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit activity"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1.5 leading-relaxed">{a.description}</p>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
<span>Date: {formatDate(a.performedAt)}</span>
</div>
</div>
<p className="text-xs text-slate-400 mt-1">{a.description}</p>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
<span>Date: {formatDate(a.performedAt)}</span>
</div>
</div>
))}
);
})}
</div>
)}
</div>
@@ -469,7 +673,7 @@ export const ProjectsPage: React.FC = () => {
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<button
onClick={() => setIsMilestoneModalOpen(true)}
onClick={openCreateMilestone}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
@@ -487,10 +691,22 @@ export const ProjectsPage: React.FC = () => {
return (
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-xs text-white">{m.name}</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<div>
<span className="font-semibold text-xs text-white block">{m.name}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<button
onClick={() => openEditMilestone(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit contractual milestone"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1">
@@ -574,12 +790,15 @@ export const ProjectsPage: React.FC = () => {
</div>
</div>
{/* Modal: Create Project */}
{/* Modal: Create or Edit Project */}
<Modal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
title="Create Funded Project"
subtitle="Operational entity under an approved Programme"
onClose={() => {
setIsProjectModalOpen(false);
setEditingProject(null);
}}
title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'}
subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'}
>
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
<div>
@@ -595,19 +814,21 @@ export const ProjectsPage: React.FC = () => {
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
<select
{...projectForm.register('programmeId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
{!editingProject && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
<select
{...projectForm.register('programmeId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
@@ -652,7 +873,10 @@ export const ProjectsPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsProjectModalOpen(false)}
onClick={() => {
setIsProjectModalOpen(false);
setEditingProject(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
@@ -662,26 +886,24 @@ export const ProjectsPage: React.FC = () => {
disabled={projectMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{projectMutation.isPending ? 'Creating...' : 'Register Project'}
{projectMutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Activity */}
{/* Modal: Add or Edit Activity */}
<Modal
isOpen={isActivityModalOpen}
onClose={() => setIsActivityModalOpen(false)}
title="Add Activity to Implementation Plan"
subtitle={`Registering activity under Project #${activeProjectId}`}
onClose={() => {
setIsActivityModalOpen(false);
setEditingActivity(null);
}}
title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'}
subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`}
>
<form
onSubmit={activityForm.handleSubmit((data) =>
activityMutation.mutate({
projectId: activeProjectId!,
...data,
} as any)
)}
onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))}
className="space-y-4"
>
<div>
@@ -694,6 +916,24 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
{/* Associated Milestone Dropdown (FR: Passing milestoneId to PUT/POST request) */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Associated Milestone (Deliverable Link)
</label>
<select
{...activityForm.register('milestoneId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- No Milestone / Standalone Activity --</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
Milestone #{m.id}: {m.name} (Due: {formatDate(m.dueAt)})
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Allocation (ZAR) *</label>
@@ -726,7 +966,10 @@ export const ProjectsPage: React.FC = () => {
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsActivityModalOpen(false)}
onClick={() => {
setIsActivityModalOpen(false);
setEditingActivity(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
@@ -736,26 +979,24 @@ export const ProjectsPage: React.FC = () => {
disabled={activityMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{activityMutation.isPending ? 'Saving...' : 'Add Activity'}
{activityMutation.isPending ? 'Saving...' : editingActivity ? 'Update Activity' : 'Add Activity'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Milestone */}
{/* Modal: Add or Edit Milestone */}
<Modal
isOpen={isMilestoneModalOpen}
onClose={() => setIsMilestoneModalOpen(false)}
title="Define Contractual Milestone"
subtitle={`Deliverable schedule for Project #${activeProjectId}`}
onClose={() => {
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
}}
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
>
<form
onSubmit={milestoneForm.handleSubmit((data) =>
milestoneMutation.mutate({
projectId: activeProjectId!,
...data,
} as any)
)}
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))}
className="space-y-4"
>
<div>
@@ -792,10 +1033,29 @@ export const ProjectsPage: React.FC = () => {
</div>
</div>
{editingMilestone && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
<select
{...milestoneForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsMilestoneModalOpen(false)}
onClick={() => {
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
@@ -805,7 +1065,7 @@ export const ProjectsPage: React.FC = () => {
disabled={milestoneMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{milestoneMutation.isPending ? 'Saving...' : 'Define Milestone'}
{milestoneMutation.isPending ? 'Saving...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
</button>
</div>
</form>
@@ -27,6 +27,8 @@ import {
getUser,
updateUserStatus,
syncIntegration,
syncUser,
getUserSyncRecord,
getInvites,
sendInvite,
getApplicantInvites,
@@ -38,6 +40,7 @@ import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import { getAppConfig } from '../config';
import { triggerSessionExpired } from '../services/sessionExpiration';
import {
EmailStatuses,
NotificationStatus,
@@ -98,13 +101,13 @@ export const SettingsPage: React.FC = () => {
});
const syncMutation = useMutation({
mutationFn: () => syncIntegration(),
mutationFn: () => syncUser(undefined, true),
onSuccess: () => {
success('Integration Sync Complete', 'Background user and tenant directory synchronized successfully.');
success('User Sync Complete', 'Authenticated user enabled and directory synced. Browser cache updated to prevent endpoint hotspot.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Sync Failed', err?.message || 'Failed to trigger background integration sync.');
toastError('Sync Failed', err);
},
});
@@ -119,7 +122,7 @@ export const SettingsPage: React.FC = () => {
}
},
onError: (err: any) => {
toastError('Update Failed', err?.message || 'Could not update user status.');
toastError('Update Failed', err);
},
});
@@ -170,7 +173,7 @@ export const SettingsPage: React.FC = () => {
setInviteMessage('');
},
onError: (err: any) => {
toastError('Dispatch Error', err?.message || 'Failed to dispatch invitation.');
toastError('Dispatch Error', err);
},
});
@@ -183,7 +186,7 @@ export const SettingsPage: React.FC = () => {
setApplicantInvitesResults(res);
success('Lookup Complete', `Retrieved ${res.length} invitation records for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err?.message || 'Applicant invitation lookup failed.');
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
@@ -224,14 +227,24 @@ export const SettingsPage: React.FC = () => {
<div className="flex items-center gap-2">
{activeTab === 'users' && (
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
{syncMutation.isPending ? 'Syncing...' : 'Sync Integration'}
</button>
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync'} (Synced in browser)` : 'Sync active in browser'}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
</span>
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
title="Force refresh user sync and update browser cache"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}
</button>
</div>
)}
{activeTab === 'invites' && (
@@ -366,8 +379,8 @@ export const SettingsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">User ID (UUID)</th>
<th className="py-3.5 px-4">Email</th>
<th className="py-3.5 px-5">User Email</th>
<th className="py-3.5 px-4">User Identifier</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Last Login</th>
<th className="py-3.5 px-5 text-right">Actions</th>
@@ -391,11 +404,11 @@ export const SettingsPage: React.FC = () => {
const badge = getActivityStatusBadge(u.status);
return (
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{u.id}
<td className="py-3.5 px-5 text-white font-medium">
{u.email || getUserEmail(u.id) || <span className="text-slate-500 italic">No email recorded</span>}
</td>
<td className="py-3.5 px-4 text-white font-medium">
{u.email || <span className="text-slate-500 italic">No email recorded</span>}
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{u.id}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
@@ -778,6 +791,31 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-400" />
Session Expiration vs Lockout Architecture (HTTP 401)
</h3>
<p className="text-xs text-slate-400 mt-0.5 max-w-2xl leading-relaxed">
To disambiguate expired sessions from the session lockout process, the session expiration handler monitors all active business endpoints for HTTP 401 other than the user sync endpoint. The user sync endpoint remains dedicated to the session lockout &amp; activation check, ensuring both features act in harmony where one is subordinated by call order and priority.
</p>
</div>
<button
type="button"
id="simulate-401-btn"
onClick={() => {
triggerSessionExpired('Simulated HTTP 401: Security token expired on business endpoint during active session test.');
}}
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition-all shadow-md shrink-0"
>
<AlertCircle className="w-3.5 h-3.5 text-amber-400" />
Simulate HTTP 401 Session Expiry
</button>
</div>
</div>
</div>
)}
@@ -939,12 +977,12 @@ export const SettingsPage: React.FC = () => {
<div className="space-y-4 text-xs">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User UUID:</span>
<span className="font-mono text-white text-xs break-all">{selectedUserForDetail.id}</span>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">{selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Email:</span>
<span className="text-white">{selectedUserForDetail.email || 'Not specified'}</span>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">{selectedUserForDetail.id}</span>
</div>
<div className="flex items-center justify-between pt-1">
<div>
+198 -28
View File
@@ -20,6 +20,7 @@ import {
UpdateProjectStatusRequest,
Activity,
CreateActivityRequest,
UpdateActivityRequest,
Milestone,
CreateMilestoneRequest,
UpdateMilestoneRequest,
@@ -51,26 +52,29 @@ import {
ActivityStatus,
} from '../types';
// Helper to extract ProblemDetails from Axios error
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
import { registerUserEmail, registerUserEmails } from '../lib/formatters';
// Helper to extract ProblemDetails from Axios error or unknown error
export function parseApiError(error: unknown): ProblemDetails {
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<ProblemDetails>;
if (axiosErr.response?.data) {
if (axiosErr.response?.data && typeof axiosErr.response.data === 'object') {
return axiosErr.response.data;
}
return {
title: 'API Request Error',
detail: axiosErr.message || 'Unable to communicate with the remote PFM service.',
status: axiosErr.response?.status || 500,
};
}
const friendly = parseFriendlyError(error);
return {
title: 'Unexpected Application Error',
detail: String(error),
status: 500,
title: friendly.title,
detail: friendly.message,
status: friendly.statusCode || 500,
};
}
export { parseFriendlyError };
export type { FriendlyError };
// ==========================================
// 1. Executive Dashboard APIs
// ==========================================
@@ -82,17 +86,53 @@ export const getDashboard = async (): Promise<Dashboard> => {
// ==========================================
// 2. Governance & Structure APIs
// ==========================================
/**
* Normalizes an Organisation object from backend responses.
* Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive).
*/
export const normalizeOrganisation = (raw: any): Organisation => {
if (!raw) return raw;
const status =
raw.status ??
raw.Status ??
raw.activityStatus ??
raw.ActivityStatus ??
raw.approvalStatus ??
raw.ApprovalStatus ??
raw.statusId ??
raw.StatusId ??
raw.state ??
raw.State ??
(raw.isActive !== undefined ? (raw.isActive ? 1 : 2) : undefined) ??
(raw.IsActive !== undefined ? (raw.IsActive ? 1 : 2) : undefined) ??
ActivityStatus.Active;
return {
...raw,
id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId,
name: raw.name ?? raw.Name ?? null,
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
email: raw.email ?? raw.Email ?? null,
type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5,
status,
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
};
};
export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organisation[]> => {
const res = await apiClient.get<Organisation[]>('/v1/api/governance/organisations', {
const res = await apiClient.get<any[]>('/v1/api/governance/organisations', {
params: { page, pageSize },
});
return res.data || [];
const list = res.data || [];
return list.map(normalizeOrganisation);
};
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
const res = await apiClient.get<Organisation>(`/v1/api/governance/organisations/${hashedOrgId}`);
return res.data;
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
return normalizeOrganisation(res.data);
};
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
@@ -275,11 +315,46 @@ export const getActivity = async (activityId: string | number): Promise<Activity
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects/activities', {
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
createdBy: userSub || undefined,
});
return res.data;
};
try {
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
}
throw err;
}
};
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
updatedBy: userSub || undefined,
};
try {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 404 || err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activity', body);
return res.data;
}
throw err;
}
};
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
@@ -324,8 +399,9 @@ export const getProjectIndicators = async (projectId: string | number): Promise<
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Risk[]>(`/v1/api/projects/${hashedProjectId}/risks`);
return res.data || [];
const res = await apiClient.get<any[]>(`/v1/api/projects/${hashedProjectId}/risks`);
const list = res.data || [];
return list.map(normalizeRisk);
};
// ==========================================
@@ -470,32 +546,85 @@ export const approveDisbursement = async (disbursementId: string | number): Prom
});
};
/**
* Normalizes a Risk record from backend responses.
* Robustly parses Likelihood (1-5) and Impact (1-5), supporting strings, numbers, and case variations.
*/
export const normalizeRisk = (raw: any): Risk => {
if (!raw) return raw;
const rawLik = raw.likelihood ?? raw.Likelihood ?? raw.riskLikelihood ?? raw.RiskLikelihood;
const rawImp = raw.impact ?? raw.Impact ?? raw.riskImpact ?? raw.RiskImpact;
const parseScore = (val: any, defaultVal: number): number => {
if (typeof val === 'number' && !isNaN(val)) {
return Math.min(5, Math.max(1, Math.round(val)));
}
if (typeof val === 'string') {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= 5) {
return parsed;
}
const lower = val.toLowerCase().trim();
if (lower.includes('rare') || lower.includes('insignificant') || lower.includes('trivial') || lower === '1') return 1;
if (lower.includes('unlikely') || lower.includes('minor') || lower === '2') return 2;
if (lower.includes('possible') || lower.includes('moderate') || lower.includes('medium') || lower === '3') return 3;
if (lower.includes('likely') || lower.includes('major') || lower.includes('high') || lower === '4') return 4;
if (lower.includes('almost') || lower.includes('certain') || lower.includes('severe') || lower.includes('critical') || lower.includes('catastrophic') || lower === '5') return 5;
}
return defaultVal;
};
const likelihood = parseScore(rawLik, 3);
const impact = parseScore(rawImp, 2);
return {
...raw,
id: raw.id ?? raw.Id ?? raw.riskId ?? raw.RiskId,
projectId: raw.projectId ?? raw.ProjectId,
name: raw.name ?? raw.Name ?? null,
description: raw.description ?? raw.Description ?? null,
likelihood,
impact,
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
owner: raw.owner ?? raw.Owner ?? raw.riskOwner ?? raw.RiskOwner ?? raw.createdBy ?? raw.CreatedBy ?? '',
mitigationAction: raw.mitigationAction ?? raw.MitigationAction ?? raw.mitigation ?? raw.Mitigation ?? '',
status: raw.status ?? raw.Status,
};
};
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
const res = await apiClient.get<Risk[]>('/v1/api/execution/risks', {
const res = await apiClient.get<any[]>('/v1/api/execution/risks', {
params: { page, pageSize },
});
return res.data || [];
const list = res.data || [];
return list.map(normalizeRisk);
};
export const getRisk = async (riskId: string | number): Promise<Risk> => {
const hashedRiskId = await toolsService.encodeId(riskId);
const res = await apiClient.get<Risk>(`/v1/api/execution/risks/${hashedRiskId}`);
return res.data;
const res = await apiClient.get<any>(`/v1/api/execution/risks/${hashedRiskId}`);
return normalizeRisk(res.data);
};
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/risks', {
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
createdBy: userSub || undefined,
});
return res.data;
return normalizeRisk(res.data);
};
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/risks', {
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
updatedBy: userSub || undefined,
});
};
@@ -592,11 +721,16 @@ export const getUsers = async (
filter: UserFilterRequest = {}
): Promise<User[]> => {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter);
return res.data || [];
const list = res.data || [];
registerUserEmails(list);
return list;
};
export const getUser = async (userId: string): Promise<User> => {
const res = await apiClient.get<User>(`/v1/api/users/${userId}`);
if (res.data?.id && res.data?.email) {
registerUserEmail(res.data.id, res.data.email);
}
return res.data;
};
@@ -604,7 +738,11 @@ export const getCurrentUser = async (): Promise<User> => {
const userSub = getCurrentUserSub();
if (userSub) {
try {
return await getUser(userSub);
const u = await getUser(userSub);
if (u?.id && u?.email) {
registerUserEmail(u.id, u.email);
}
return u;
} catch {
// Fallback
}
@@ -621,8 +759,40 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export const syncIntegration = async (): Promise<void> => {
await apiClient.post('/v1/api/integration/sync');
export {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
saveUserSyncInBrowser,
clearUserSyncInBrowser,
getUserSyncRecord,
isActivationPending,
setActivationPending,
getActivationPendingReason,
} from './userSyncService';
/**
* Synchronizes the authenticated user with the backend to enable the user in the system.
* By default, sync is saved in the browser to prevent hotspotting the sync endpoint.
*/
export const syncUser = async (
userData?: {
id?: string;
sub?: string;
email?: string | null;
name?: string | null;
role?: string | null;
tenant?: string | null;
},
force = false
): Promise<boolean> => {
const { ensureUserSynced } = await import('./userSyncService');
return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force);
};
export const syncIntegration = async (force = true): Promise<void> => {
const { ensureUserSynced } = await import('./userSyncService');
await ensureUserSynced(undefined, undefined, undefined, force);
};
// ==========================================
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
export const BASE_API_URL = getApiBaseUrl();
@@ -15,14 +16,69 @@ export const apiClient = axios.create({
// Request Interceptor to dynamically set baseURL and attach Bearer Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration
config.baseURL = getApiBaseUrl();
const token = localStorage.getItem('pfm_access_token');
if (token && config.headers) {
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) {
const { isUserSyncedInBrowser, isActivationPending, syncUserAccount } = await import('./userSyncService');
// 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;
},
(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);
// Disambiguation between Session Lockout and Session Expired:
// - Session Expired process monitors ALL OTHER endpoints for HTTP 401 other than the user sync endpoint.
// - User sync endpoint 401 is subordinated to the session lockout / auth initialization flow through call order and priority.
if (!isSyncEndpoint) {
// Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect
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: clean up session data silently for landing navigation
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,159 @@
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.
*
* NOTE: As established by system call priority and subordination, this process
* strictly monitors all other endpoints EXCEPT the user sync endpoint, which is
* reserved for the session lockout / activation process.
*/
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
// Disambiguation: Ignore HTTP 401 if originating from the user sync endpoint
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
// Priority Subordination: If user is in account activation lockout, session expiration
// is avoided and cancelled in favor of the higher-priority activation modal.
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;
// Session expiration modal dismisses all other subordinates when it is displayed
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('/');
}
@@ -0,0 +1,337 @@
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;
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/users/sync',
'/v1/api/integration/sync',
'/v1/api/user/sync',
];
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This is used to differentiate user sync / lockout lifecycle calls from regular
* business working endpoints, ensuring established call priority and subordination.
*/
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'
);
}
/**
* Executes the backend user sync request.
* Tries the primary '/v1/api/users/sync' endpoint, falling back to
* '/v1/api/integration/sync' or '/v1/api/user/sync' if needed.
*/
async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
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,
};
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
let lastError: any = null;
for (const endpoint of candidateEndpoints) {
try {
const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast
if (status === 401) {
throw err;
}
// 2. If HTTP 403 Forbidden, 423 Locked, or non-404 client error:
// The endpoint is valid, but the user is blocked/disabled/not activated by the administrator.
if (status === 403 || status === 423 || (status && status >= 400 && status !== 404 && status !== 405)) {
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;
}
+12
View File
@@ -443,6 +443,18 @@ export interface CreateActivityRequest {
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 {
id: number | string;
projectId: number | string;
@@ -0,0 +1,494 @@
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 }>;
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);
}
/**
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
*/
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
let 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();
// If after cleaning it's empty or trivial, return empty
if (cleaned.length < 3 || cleaned === '[object Object]') {
return '';
}
return cleaned;
}
/**
* Checks if a title is a standard framework generic title.
*/
function isGenericFrameworkTitle(title?: string): boolean {
if (!title) return true;
const lower = title.toLowerCase().trim();
return (
lower === 'bad request' ||
lower === 'one or more validation errors occurred.' ||
lower === 'one or more validation errors occurred' ||
lower === 'forbidden' ||
lower === 'not found' ||
lower === 'internal server error' ||
lower === 'an error occurred while processing your request.' ||
lower === 'api request error' ||
lower.startsWith('request failed with')
);
}
/**
* 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
);
}
/**
* 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,
* prioritizing the standardised ProblemDetails model when present,
* and handling BadRequest specifically for its multiple purposes:
* - Input validation errors (field-level)
* - Creation errors (prerequisites, business rules, duplicate identifiers)
* - Update errors (lifecycle state restrictions, invalid transitions, permissions)
*/
export function parseFriendlyError(
error: unknown,
fallbackActionTitle = 'Action Failed'
): FriendlyError {
let statusCode: number | undefined;
let rawTitle: string | undefined;
let rawDetail: string | undefined;
let rawErrors: Record<string, string[] | string> | 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') {
if (isProblemDetailsObject(rawResponseData)) {
isProblemDetails = true;
rawTitle = rawResponseData.title || undefined;
rawDetail = rawResponseData.detail || undefined;
statusCode = rawResponseData.status || statusCode;
rawErrors = rawResponseData.errors;
} else if (rawResponseData.message || rawResponseData.error) {
rawDetail = rawResponseData.message || rawResponseData.error;
}
} else if (typeof rawResponseData === 'string') {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData);
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || undefined;
rawDetail = parsed.detail || undefined;
statusCode = parsed.status || statusCode;
rawErrors = parsed.errors;
} 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 || undefined;
rawDetail = obj.detail || undefined;
statusCode = obj.status || undefined;
rawErrors = obj.errors;
} else if (obj.response?.data && isProblemDetailsObject(obj.response.data)) {
isProblemDetails = true;
rawTitle = obj.response.data.title || undefined;
rawDetail = obj.response.data.detail || undefined;
statusCode = obj.response.status || obj.response.data.status;
rawErrors = obj.response.data.errors;
} 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, e.g. String(err)
const statusMatch = error.match(/status code (\d{3})/i);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
}
rawDetail = cleanTechnicalJargon(error);
}
// Parse field-level validation errors from ProblemDetails if present
const fieldErrors: Array<{ field: string; message: string }> = [];
if (rawErrors && typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const fieldTitle = humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(String(msg)) });
});
} else if (typeof val === 'string' && val) {
fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(val) });
}
}
}
// Detect whether the operation is creation, update, deletion, or general
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
// Build user-friendly title, message, and category based on standard HTTP codes and ProblemDetails
let friendlyTitle = fallbackActionTitle;
let friendlyMessage = '';
let category: ErrorCategory = 'general';
let categoryLabel: string | undefined = undefined;
switch (statusCode) {
case 403:
// Forbid: specifically occurs when the hashid used on a call is invalid or access denied
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'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:
// NotFound: item could not be located
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
case 400: {
// BadRequest serves MULTIPLE purposes:
// 1) Input validation errors (field-level errors dictionary)
// 2) Creation errors (prerequisites, business rules, duplicate code, invalid entity state)
// 3) Update errors (status transition restrictions, record locked, state mismatch)
const hasSpecificTitle = rawTitle && !isGenericFrameworkTitle(rawTitle);
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
if (fieldErrors.length > 0) {
// Purpose 1: Input Validation Errors
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Creation Validation'
: operationIntent === 'update'
? 'Update Validation'
: 'Input Validation';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (operationIntent === 'creation') {
friendlyTitle = 'Please Check New Record Details';
} else if (operationIntent === 'update') {
friendlyTitle = 'Please Check Updated Values';
} else {
friendlyTitle = 'Please Check Submitted Details';
}
const errorList = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
friendlyMessage = cleanDetail
? `${cleanDetail}\n\n${errorList}`
: `The submitted details could not be validated:\n${errorList}`;
} else if (operationIntent === 'creation') {
// Purpose 2: Creation Error (non-field-validation: business rule, duplicate, or prerequisite failure)
category = 'creation';
categoryLabel = 'Creation Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Create Record';
}
friendlyMessage =
cleanDetail ||
'The new record could not be created. This may be due to business rule prerequisites, existing duplicate identifiers, or missing related records.';
} else if (operationIntent === 'update') {
// Purpose 3: Update Error (non-field-validation: status transition, record locked, lifecycle restriction)
category = 'update';
categoryLabel = 'Update Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Update Record';
}
friendlyMessage =
cleanDetail ||
'The record could not be updated. It may be locked, in a status that does not allow modifications, or the requested status transition is not permitted.';
} else {
// General Bad Request
category = 'general';
categoryLabel = 'Request Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else {
friendlyTitle = fallbackActionTitle || 'Request Could Not Be Processed';
}
friendlyMessage =
cleanDetail ||
'The request could not be processed due to invalid parameters or business rule requirements. Please review your input and try again.';
}
break;
}
case 409:
// Conflict
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'This action conflicts with the current status of the record (for instance, another user may have updated it or a duplicate entry exists). Please refresh the page and try again.';
break;
case 422:
// Unprocessable Entity
category = 'validation';
categoryLabel = 'Incomplete Data';
friendlyTitle = 'Information Incomplete';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The submitted information could not be processed by the system. Please verify that all required fields are complete.';
break;
case 429:
// Too Many Requests
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:
// Server error
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:
if (rawTitle && isProblemDetails && !isGenericFrameworkTitle(rawTitle)) {
friendlyTitle = cleanTechnicalJargon(rawTitle) || fallbackActionTitle;
}
if (rawDetail) {
friendlyMessage = cleanTechnicalJargon(rawDetail);
} else if (fieldErrors.length > 0) {
category = 'validation';
categoryLabel = 'Validation';
friendlyMessage = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
} else {
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,
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}
@@ -2,6 +2,8 @@ namespace PostFundManagement.Domain.Extensions;
public static class Constants
{
public const string ServiceCachingFeature = "EnableServiceCaching";
public const string MainScope = "ApiScope";
public const string CorsPolicy = "AllowConfiguredOrigins";
@@ -8,15 +8,15 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea
{
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.");
if (!scheduler.IsStarted)
if (scheduler.Status != SchedulerStatus.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");
}
@@ -12,9 +12,10 @@
<!-- Transformation -->
<ItemGroup>
<PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.11" />
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
<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" />
<Using Include="Humanizer" />
@@ -27,12 +28,12 @@
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
<PackageReference Include="Polly" Version="8.7.0" />
<PackageReference Include="Polly.Extensions" Version="8.7.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageReference Include="Polly" Version="8.8.0" />
<PackageReference Include="Polly.Extensions" Version="8.8.0" />
<Using Include="System.Security.Claims" />
<Using Include="AccessTokenClient" />
@@ -62,8 +63,8 @@
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.4" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
@@ -73,17 +74,17 @@
<!-- Quartz Scheduler-->
<ItemGroup>
<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>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.18.0" />
<PackageReference Include="Quartz" Version="3.20.0" />
<PackageReference Include="Quartz.Plugins" Version="3.20.0" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.20.0" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.20.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.20.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.20.0" />
<PackageReference Include="Quartz" Version="4.1.0" />
<PackageReference Include="Quartz.Plugins" Version="4.1.0" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
<PackageReference Include="Quartz.AspNetCore" Version="4.1.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
<!-- Global Usings -->
<Using Include="Quartz" />
@@ -91,11 +92,11 @@
<!-- Configuration -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" />
@@ -108,9 +109,9 @@
<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.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -136,18 +137,18 @@
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<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 -->
<Using Include="Npgsql" />
@@ -159,8 +160,8 @@
<!-- Email -->
<ItemGroup>
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.17.0" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="MimeKit" Version="4.18.0" />
<!-- Global Usings-->
<Using Include="MimeKit" />
@@ -179,8 +180,8 @@
<!-- Amazon S3 SDK -->
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.1" />
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
<PackageReference Include="AWSSDK.S3" Version="4.0.103.3" />
<!-- global Usings -->
<Using Include="Amazon.S3" />
@@ -26,7 +26,7 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
.StartNow()
.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)
@@ -51,17 +51,16 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
var trigger = global::Quartz.TriggerBuilder.Create()
.WithIdentity(triggerKey)
.WithDescription($"Scheduled via Main Job at {now:g}")
.WithCronSchedule(cronExpression, cron => cron
.WithMisfireHandlingInstructionIgnoreMisfires())
.WithCronSchedule(cronExpression, cron => cron.WithMisfireInstruction(CronTriggerMisfireInstruction.IgnoreMisfires))
.StartAt((DateTimeOffset)now)
.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);
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)
@@ -82,6 +81,6 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr
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]
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)
Trace.WriteLine($"CRITICAL RECOVERY: Resurrecting job '{typeof(TNotification).Name}' after a previous cluster node crashed mid-execution.");
@@ -43,7 +44,7 @@ public sealed class MediatorJob<TNotification>(IMediator mediator) : IJob where
catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
{
Trace.WriteLine($"Job '{typeof(TNotification).Name}' was gracefully interrupted by the cluster control plane.");
activity?.SetStatus(ActivityStatusCode.Ok);
return;
@@ -16,6 +16,23 @@ public sealed class RetryJobListener : IJobListener
return;
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 -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -32,9 +32,9 @@
<!-- Extensions -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<Using Include="Microsoft.Extensions.Configuration" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
+5
View File
@@ -9,6 +9,10 @@ services:
ports:
- "8075:8080"
environment:
# Service layer caching feature flag
- EnableServiceCaching=false
# Header forwarding overrides
- ASPNETCORE_ENVIRONMENT=${ENVIRONMENT:-Development}
- ForwardedHeadersConfiguration__Enabled=true
- ForwardedHeadersConfiguration__AllowAll=true
@@ -21,6 +25,7 @@ services:
# 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__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
- GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za}