Compare commits
202
Commits
25e251aa79
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f132eeb29 | ||
|
|
80c798e5e6 | ||
|
|
2690b3230e | ||
|
|
155a379ab4 | ||
|
|
f56cb70b55 | ||
|
|
bb77a4dc9a | ||
|
|
5ac317cbe3 | ||
|
|
0bcc3484dc | ||
|
|
fddf400ddd | ||
|
|
a1b8410bc2 | ||
|
|
cb0da32ce4 | ||
|
|
70e286670e | ||
|
|
c6075a5c39 | ||
|
|
4e1211a1e1 | ||
|
|
c8e54815ef | ||
|
|
31d775c917 | ||
|
|
78d147b002 | ||
|
|
07018c3ce4 | ||
|
|
db08bbf162 | ||
|
|
ac357285c3 | ||
|
|
a4927f9742 | ||
|
|
fac88f3471 | ||
|
|
aeb7542cfe | ||
|
|
9724bbac24 | ||
|
|
deb21dbb9a | ||
|
|
98fe1e3e3d | ||
|
|
48edfa1cbb | ||
|
|
d9b65010cf | ||
|
|
3e1d8bc1cc | ||
|
|
bc441c1255 | ||
|
|
17a4149a73 | ||
|
|
6e786ca796 | ||
|
|
90631d22ae | ||
|
|
010f2d5eaa | ||
|
|
b8f5ebf69a | ||
|
|
957e75a853 | ||
|
|
8a06a86eb3 | ||
|
|
f8f826979d | ||
|
|
c4a0469bae | ||
|
|
a3f461478c | ||
|
|
cc62696017 | ||
|
|
d4598e0c6c | ||
|
|
43656e848a | ||
|
|
3a3245b643 | ||
|
|
fdaf12a183 | ||
|
|
6cdd03a02f | ||
|
|
1d1060d031 | ||
|
|
b0c89362d0 | ||
|
|
dee9ccf0b1 | ||
|
|
d9b3e80f7c | ||
|
|
8688c752d6 | ||
|
|
937a5595d8 | ||
|
|
58ce606410 | ||
|
|
a98bfa4308 | ||
|
|
6e56a4c223 | ||
|
|
e38edd24a0 | ||
|
|
1ac0018926 | ||
|
|
5ad59f6569 | ||
|
|
71a0abf0b2 | ||
|
|
627885e36b | ||
|
|
447ffb366e | ||
|
|
a9bb319eb1 | ||
|
|
bbba869cdd | ||
|
|
5ae2622454 | ||
|
|
bd0f03344d | ||
|
|
1af860fdc1 | ||
|
|
c04ae9a933 | ||
|
|
0e853811a9 | ||
|
|
3a3d5daefc | ||
|
|
fe9b9052e9 | ||
|
|
d25545ddad | ||
|
|
7effc32498 | ||
|
|
8909bcf5c4 | ||
|
|
411e603f51 | ||
|
|
138ef84973 | ||
|
|
312721bf51 | ||
|
|
dd59294911 | ||
|
|
28a3a17a15 | ||
|
|
4c2b0cd7d5 | ||
|
|
275fd109e6 | ||
|
|
248cc2fa2d | ||
|
|
761d14beb9 | ||
|
|
931e1a8698 | ||
|
|
aff58caeb0 | ||
|
|
1c5080bf04 | ||
|
|
1fb157aa0a | ||
|
|
0adfae1616 | ||
|
|
4856b6e6f4 | ||
|
|
0ba47269aa | ||
|
|
5e25fd7a11 | ||
|
|
f8fff04065 | ||
|
|
aca5882ad3 | ||
|
|
6b027193d1 | ||
|
|
81702a4bf8 | ||
|
|
5eeb1cbfc7 | ||
|
|
fb00986f59 | ||
|
|
0338737385 | ||
|
|
82c0936b4c | ||
|
|
5d2a886f6f | ||
|
|
dc4047ca6d | ||
|
|
46fe14d9ef | ||
|
|
f17368d939 | ||
|
|
7aea3fa8de | ||
|
|
d43eb17aa3 | ||
|
|
01547d8c3f | ||
|
|
18dd9131cb | ||
|
|
f82a5cef76 | ||
|
|
614730caab | ||
|
|
9823cd7ea6 | ||
|
|
a7a68fc290 | ||
|
|
4dbc74e488 | ||
|
|
d658497545 | ||
|
|
b947959724 | ||
|
|
58126e7573 | ||
|
|
2fede443cc | ||
|
|
80f220fcae | ||
|
|
06e2c3ee6a | ||
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
public void Map(IEndpointRouteBuilder builder)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
|
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
public void Map(IEndpointRouteBuilder builder)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
|
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
|
||||||
|
IProjectService service, CancellationToken cancellationToken = default) =>
|
||||||
|
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
||||||
|
{
|
||||||
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
|
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
|
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||||
|
})
|
||||||
.WithDescription("Add a new beneficiary outcome")
|
.WithDescription("Add a new beneficiary outcome")
|
||||||
.RequireAuthorization(Constants.MainScope)
|
.RequireAuthorization(Constants.MainScope)
|
||||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
|
||||||
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
|
|||||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.WithTags(EndpointTags.Projects);
|
.WithTags(EndpointTags.Projects);
|
||||||
|
|
||||||
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
|
|
||||||
.WithDescription("Add a new beneficiary outcome (legacy route)")
|
|
||||||
.RequireAuthorization(Constants.MainScope)
|
|
||||||
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
|
|
||||||
.MapToApiVersion(new ApiVersion(1))
|
|
||||||
.Produces(StatusCodes.Status200OK)
|
|
||||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
|
||||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
|
||||||
.WithTags(EndpointTags.Projects);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
|
|
||||||
IProjectService service, CancellationToken cancellationToken = default) =>
|
|
||||||
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
|
|
||||||
{
|
|
||||||
{ IsSuccess: true } => Results.Ok(),
|
|
||||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
|
||||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ public interface ICommunicationsService
|
|||||||
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
[Audit]
|
|
||||||
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed record InviteRequest([Required] long OrganisationId,
|
|||||||
[Required] long InvitedOrganisationId,
|
[Required] long InvitedOrganisationId,
|
||||||
[Required] long AwardId,
|
[Required] long AwardId,
|
||||||
[Required] long ContractId,
|
[Required] long ContractId,
|
||||||
|
[Required] long ProjectId,
|
||||||
[Required] Guid Recipient,
|
[Required] Guid Recipient,
|
||||||
[Required][MaxLength(LabelLength)] string Message,
|
[Required][MaxLength(LabelLength)] string Message,
|
||||||
[Required] NotificationPlatform Platform);
|
[Required] NotificationPlatform Platform);
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
|||||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||||
|
|
||||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||||
|
|
||||||
var changedTotal = await context.Organisations
|
var changedTotal = await context.Organisations
|
||||||
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
|||||||
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
||||||
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
||||||
.SetProperty(u => u.Type, request.Type)
|
.SetProperty(u => u.Type, request.Type)
|
||||||
|
.SetProperty(u => u.UserId, request.OrganisationUser)
|
||||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||||
|
|
||||||
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
||||||
|
|||||||
@@ -161,11 +161,6 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
|||||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||||
return Result.Fail(NotFoundError.New);
|
return Result.Fail(NotFoundError.New);
|
||||||
|
|
||||||
var hasApprovedAward = await context.Awards
|
|
||||||
.AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
|
|
||||||
if (!hasApprovedAward)
|
|
||||||
return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist."));
|
|
||||||
|
|
||||||
var budget = new Domain.Entities.Budget
|
var budget = new Domain.Entities.Budget
|
||||||
{
|
{
|
||||||
ProjectId = request.ProjectId,
|
ProjectId = request.ProjectId,
|
||||||
|
|||||||
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
|
|||||||
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
|
||||||
return Result.Fail(NotFoundError.New);
|
return Result.Fail(NotFoundError.New);
|
||||||
|
|
||||||
if (request.Status == ApprovalStatus.Approved)
|
|
||||||
{
|
|
||||||
var hasUnmetCompliance = await context.ComplianceItems
|
|
||||||
.AnyAsync(c => c.ProjectId == request.ProjectId && c.Status == ComplianceStatus.NotMet, cancellationToken);
|
|
||||||
if (hasUnmetCompliance)
|
|
||||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some compliance items are not met."));
|
|
||||||
|
|
||||||
var projectBeneficiaryIds = await context.Beneficiaries
|
|
||||||
.Where(b => b.ProjectId == request.ProjectId)
|
|
||||||
.Select(b => b.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (projectBeneficiaryIds.Count > 0)
|
|
||||||
{
|
|
||||||
var hasUnmetOutcomes = await context.Outcomes
|
|
||||||
.AnyAsync(o => projectBeneficiaryIds.Contains(o.BeneficiaryId) && o.Status == ComplianceStatus.NotMet, cancellationToken);
|
|
||||||
if (hasUnmetOutcomes)
|
|
||||||
return Result.Fail(ValidationError.Create("Project cannot be approved because some beneficiary outcomes are not met."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var changedTotal = await context.Projects
|
var changedTotal = await context.Projects
|
||||||
.Where(f => f.Id == request.ProjectId)
|
.Where(f => f.Id == request.ProjectId)
|
||||||
.ExecuteUpdateAsync(f => f
|
.ExecuteUpdateAsync(f => f
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
|||||||
|
|
||||||
int[] decoded = hasher.Decode(hash);
|
int[] decoded = hasher.Decode(hash);
|
||||||
|
|
||||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
|
if (decoded.Length <= 0)
|
||||||
.ToResult(decoded[0]);
|
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
|
||||||
|
|
||||||
|
return Result.Ok(decoded[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<long> DecodeLongIdHash(string hash)
|
public Result<long> DecodeLongIdHash(string hash)
|
||||||
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
|
|||||||
|
|
||||||
long[] decoded = hasher.DecodeLong(hash);
|
long[] decoded = hasher.DecodeLong(hash);
|
||||||
|
|
||||||
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
|
if (decoded.Length <= 0)
|
||||||
.ToResult(decoded[0]);
|
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
|
||||||
|
|
||||||
|
return Result.Ok(decoded[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
public async Task<Result<string>> DecodeHexHash(string hex) =>
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ VITE_ENABLE_HTTPS="true"
|
|||||||
|
|
||||||
# HasherSettings for HashIds compatibility with backend pfm-api
|
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||||
VITE_HASHER_MIN_LENGTH="11"
|
VITE_HASHER_MIN_LENGTH="11"
|
||||||
|
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
|
||||||
|
|||||||
@@ -30,16 +30,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
|
|||||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
# 6. Hasher Settings
|
# 6. Hasher Settings
|
||||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||||
|
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||||
|
|
||||||
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
||||||
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
|
|
||||||
cat <<EOF > "$TARGET_DIR/env-config.js"
|
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||||
// Generated dynamically at container startup from deployment environment variables
|
// Generated dynamically at container startup from deployment environment variables
|
||||||
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
|
|
||||||
window.__RUNTIME_CONFIG__ = {
|
window.__RUNTIME_CONFIG__ = {
|
||||||
API_BASE_URL: "${API_BASE_URL}",
|
API_BASE_URL: "${API_BASE_URL}",
|
||||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||||
@@ -53,6 +52,8 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
|
HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: "${API_BASE_URL}"
|
BaseUrl: "${API_BASE_URL}"
|
||||||
},
|
},
|
||||||
@@ -63,6 +64,7 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
Scope: "${OIDC_SCOPE}"
|
Scope: "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
|
Salt: "${HASHER_SALT}",
|
||||||
MinHashLength: "${HASHER_MIN_LENGTH}"
|
MinHashLength: "${HASHER_MIN_LENGTH}"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,6 +83,7 @@ EOF
|
|||||||
"Scope": "${OIDC_SCOPE}"
|
"Scope": "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
|
"Salt": "${HASHER_SALT}",
|
||||||
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"Scope": ""
|
"Scope": ""
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
"MinHashLength": ""
|
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
"MinHashLength": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
// Development and fallback runtime configuration.
|
// Development and fallback runtime configuration.
|
||||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||||
|
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
HASHER_MIN_LENGTH: "11",
|
||||||
|
VITE_HASHER_MIN_LENGTH: "11",
|
||||||
|
HasherSettings: {
|
||||||
|
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||||
|
MinHashLength: "11"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { NotificationBell } from './NotificationBell';
|
import { NotificationBell } from './NotificationBell';
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
|
||||||
|
if (collapsedSections[path] !== undefined) {
|
||||||
|
return collapsedSections[path];
|
||||||
|
}
|
||||||
|
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (path: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[path]: !isSectionCollapsed(path),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -186,7 +207,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||||
@@ -194,7 +214,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
queryKey: ['active-tenant-header', tenantId],
|
queryKey: ['active-tenant-header', tenantId],
|
||||||
queryFn: () => getTenant(tenantId!),
|
queryFn: () => getTenant(tenantId!),
|
||||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure current logged-in user is registered in the email cache
|
// Ensure current logged-in user is registered in the email cache
|
||||||
@@ -346,13 +365,16 @@ export const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.path} className="space-y-1">
|
<div key={item.path} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path}
|
to={item.path}
|
||||||
end={hasSub}
|
end={hasSub}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
@@ -363,9 +385,25 @@ export const AppLayout: React.FC = () => {
|
|||||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||||
<span className="truncate flex-1">{item.name}</span>
|
<span className="truncate flex-1">{item.name}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{hasSub && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => toggleSection(item.path, e)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
|
||||||
|
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||||
|
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||||
|
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sub-items under parent item */}
|
{/* Sub-items under parent item */}
|
||||||
{hasSub && (
|
{hasSub && !isCollapsed && (
|
||||||
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||||
{item.subItems?.map((subItem) => {
|
{item.subItems?.map((subItem) => {
|
||||||
const SubIcon = subItem.icon;
|
const SubIcon = subItem.icon;
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||||
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
<span>Active / Executed</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
title="Approved evidence is immutable. Status cannot be modified."
|
title="Approved evidence is immutable. Status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const activitySchema = z.object({
|
|||||||
title: z.string().min(3, 'Activity title required'),
|
title: z.string().min(3, 'Activity title required'),
|
||||||
description: z.string().min(5, 'Description required'),
|
description: z.string().min(5, 'Description required'),
|
||||||
category: z.string().min(2, 'Category required'),
|
category: z.string().min(2, 'Category required'),
|
||||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
|
||||||
milestoneId: z.string().optional().nullable(),
|
milestoneId: z.string().optional().nullable(),
|
||||||
performedAt: z.string().optional().nullable(),
|
performedAt: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
);
|
);
|
||||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||||
|
|
||||||
|
const { data: disbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements', projectId],
|
||||||
|
queryFn: () => getDisbursements(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectDisbursements = disbursements.filter(
|
||||||
|
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||||
|
);
|
||||||
|
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||||
|
|
||||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const projectValue = totalActivityBudget - totalDisbursed;
|
||||||
|
const projectVariance = approvedBudgetTotal - projectValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||||
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||||
|
Project Value: {formatZAR(projectValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono font-semibold shrink-0 ${
|
||||||
|
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||||
|
}`}
|
||||||
|
title="Approved Budget minus Net Project Value"
|
||||||
|
>
|
||||||
|
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
updateProjectStatus,
|
updateProjectStatus,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||||
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
);
|
);
|
||||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||||
|
|
||||||
// Milestone Progress
|
// Milestone Progress & Disbursements
|
||||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
|
||||||
|
const { data: disbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements', project?.id],
|
||||||
|
queryFn: () => getDisbursements(1, 100),
|
||||||
|
enabled: !!project?.id,
|
||||||
|
});
|
||||||
|
const projectDisbursements = disbursements.filter(
|
||||||
|
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||||
|
);
|
||||||
|
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||||
|
const projectValue = totalActivityBudget - totalDisbursed;
|
||||||
|
const projectVariance = approvedBudgetTotal - projectValue;
|
||||||
|
|
||||||
// Schedule Risk
|
// Schedule Risk
|
||||||
const scheduleRisk = useMemo(() => {
|
const scheduleRisk = useMemo(() => {
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
Budget: {formatZAR(approvedBudgetTotal)}
|
Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span className="text-blue-400 font-mono font-bold">
|
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||||
Project Value: {formatZAR(totalActivityBudget)}
|
Project Value: {formatZAR(projectValue)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span
|
<span
|
||||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||||
approvedBudgetTotal - totalActivityBudget >= 0
|
projectVariance >= 0
|
||||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||||
}`}
|
}`}
|
||||||
title="Approved Budget minus Total Project Value"
|
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
|
||||||
>
|
>
|
||||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
{formatZAR(projectVariance)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
|||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||||
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { user: currentAuthUser } = useAuth();
|
||||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||||
|
|
||||||
|
const userId = user?.id || '';
|
||||||
|
const isSelf =
|
||||||
|
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
|
||||||
|
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
|
||||||
|
|
||||||
const canManageRolesAndClaims =
|
const canManageRolesAndClaims =
|
||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
canApprove('tenant_management') ||
|
canApprove('tenant_management') ||
|
||||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
hasTenantSelfHelpAccess ||
|
hasTenantSelfHelpAccess ||
|
||||||
isUnrestricted;
|
isUnrestricted;
|
||||||
const canChangeStatus =
|
const canChangeStatus =
|
||||||
canAdd('settings_users') ||
|
!isSelf &&
|
||||||
|
(canAdd('settings_users') ||
|
||||||
canAdd('tenant_management') ||
|
canAdd('tenant_management') ||
|
||||||
canAdd('tenant_self_service') ||
|
canAdd('tenant_self_service') ||
|
||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
isUnrestricted;
|
isUnrestricted);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||||
|
|
||||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||||
|
|
||||||
const userId = user?.id || '';
|
|
||||||
|
|
||||||
// 1. Fetch fresh user details
|
// 1. Fetch fresh user details
|
||||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||||
queryKey: ['user-detail', userId],
|
queryKey: ['user-detail', userId],
|
||||||
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
mutationFn: (newStatus: ActivityStatus) => {
|
||||||
|
if (isSelf) {
|
||||||
|
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
|
||||||
|
}
|
||||||
|
return updateUserStatus(userId, newStatus);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
success('Status Updated', 'User activity status changed.');
|
success('Status Updated', 'User activity status changed.');
|
||||||
refetchUser();
|
refetchUser();
|
||||||
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
{isSelf ? (
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
|
||||||
|
title="Maker-Checker policy: Administrators cannot change their own account activity status"
|
||||||
|
>
|
||||||
|
Self-Account (Protected)
|
||||||
|
</span>
|
||||||
|
) : effectiveUser?.status === ActivityStatus.Active ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const defaultApiSettings: ApiSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const defaultHasherSettings: HasherSettings = {
|
export const defaultHasherSettings: HasherSettings = {
|
||||||
Salt: '',
|
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
MinHashLength: 11,
|
MinHashLength: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
Salt: salt || '',
|
Salt: salt || defaultHasherSettings.Salt,
|
||||||
MinHashLength: minHashLength,
|
MinHashLength: minHashLength,
|
||||||
},
|
},
|
||||||
ConfigSource: source,
|
ConfigSource: source,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
createNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
|
archiveNotification,
|
||||||
updateNotification,
|
updateNotification,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -124,7 +125,6 @@ export function useInAppNotifications() {
|
|||||||
},
|
},
|
||||||
// Live polling every 30 seconds
|
// Live polling every 30 seconds
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||||
@@ -137,7 +137,10 @@ export function useInAppNotifications() {
|
|||||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
n =>
|
||||||
|
!trashedIds.includes(String(n.id)) &&
|
||||||
|
Number(n.status) !== NotificationStatus.Archived &&
|
||||||
|
Number(n.status) !== NotificationStatus.Draft
|
||||||
);
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
@@ -168,7 +171,9 @@ export function useInAppNotifications() {
|
|||||||
|
|
||||||
// Trashed notifications (In-App only)
|
// Trashed notifications (In-App only)
|
||||||
const trashedNotifications = useMemo(() => {
|
const trashedNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
return inAppNotifications.filter(
|
||||||
|
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||||
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
// Mutation to mark notification as read
|
// Mutation to mark notification as read
|
||||||
@@ -211,8 +216,8 @@ export function useInAppNotifications() {
|
|||||||
});
|
});
|
||||||
}, [starredStorageKey]);
|
}, [starredStorageKey]);
|
||||||
|
|
||||||
// Delete message (move to trash)
|
// Delete message (NotificationStatus == Archived & move to trash)
|
||||||
const deleteMessage = useCallback((id: string | number) => {
|
const deleteMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||||
@@ -222,10 +227,22 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [trashStorageKey]);
|
|
||||||
|
|
||||||
// Restore from trash
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
const restoreMessage = useCallback((id: string | number) => {
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await archiveNotification(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
|
// Restore from trash / unarchive
|
||||||
|
const restoreMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.filter(x => x !== strId);
|
const next = prev.filter(x => x !== strId);
|
||||||
@@ -235,7 +252,19 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [trashStorageKey]);
|
|
||||||
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markNotificationAsRead(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
// Permanent delete
|
// Permanent delete
|
||||||
const permanentDelete = useCallback((id: string | number) => {
|
const permanentDelete = useCallback((id: string | number) => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
|||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
|
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case NotificationStatus.Archived:
|
||||||
|
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||||
case NotificationStatus.Draft:
|
case NotificationStatus.Draft:
|
||||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||||
case NotificationStatus.Delivered:
|
case NotificationStatus.Delivered:
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
// Remove caching from all pages: zero stale time and zero cache retention
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 0,
|
||||||
refetchOnReconnect: true,
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always',
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||||
|
const RETAINED_CACHE_OPTIONS = {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||||
|
|||||||
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateCompMutation = useMutation({
|
const updateCompMutation = useMutation({
|
||||||
mutationFn: (vals: ComplianceFormValues) => {
|
mutationFn: (vals: ComplianceFormValues) => {
|
||||||
if (!editingComp) throw new Error('No item selected');
|
if (!editingComp) throw new Error('No item selected');
|
||||||
|
if (
|
||||||
|
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||||
|
isMaker(editingComp.createdBy)
|
||||||
|
) {
|
||||||
|
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
|
||||||
|
}
|
||||||
return updateComplianceItem({
|
return updateComplianceItem({
|
||||||
complianceItemId: editingComp.id,
|
complianceItemId: editingComp.id,
|
||||||
title: vals.title,
|
title: vals.title,
|
||||||
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateOutcomeMutation = useMutation({
|
const updateOutcomeMutation = useMutation({
|
||||||
mutationFn: (vals: OutcomeFormValues) => {
|
mutationFn: (vals: OutcomeFormValues) => {
|
||||||
if (!editingOutcome) throw new Error('No outcome selected for update');
|
if (!editingOutcome) throw new Error('No outcome selected for update');
|
||||||
|
if (
|
||||||
|
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
|
||||||
|
isMaker(editingOutcome.createdBy)
|
||||||
|
) {
|
||||||
|
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
|
||||||
|
}
|
||||||
return updateOutcome({
|
return updateOutcome({
|
||||||
outcomeId: editingOutcome.id,
|
outcomeId: editingOutcome.id,
|
||||||
name: vals.name,
|
name: vals.name,
|
||||||
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...compForm.register('status')}
|
{...compForm.register('status')}
|
||||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...outcomeForm.register('status')}
|
{...outcomeForm.register('status')}
|
||||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||||
>
|
>
|
||||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Resolved & Enacted (Immutable)</span>
|
<span>Resolved & Enacted</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
title="Approved and enacted change requests are immutable."
|
title="Approved and enacted change requests are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Enacted</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Mail,
|
Mail,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
Layers,
|
Layers,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Search,
|
Search,
|
||||||
@@ -24,9 +25,11 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
CheckCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -69,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||||
import { RulesSection } from '../components/governance/RulesSection';
|
import { RulesSection } from '../components/governance/RulesSection';
|
||||||
@@ -110,6 +114,7 @@ const organisationSchema = z.object({
|
|||||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||||
email: z.string().email('Enter a valid email address'),
|
email: z.string().email('Enter a valid email address'),
|
||||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||||
|
idNumber: z.string().optional(),
|
||||||
type: z.coerce.number().min(1).max(27),
|
type: z.coerce.number().min(1).max(27),
|
||||||
status: z.coerce.number().optional(),
|
status: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -134,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|||||||
|
|
||||||
export const GovernancePage: React.FC = () => {
|
export const GovernancePage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sub, user, claims } = useAuth();
|
const { sub, user, claims, roles, tenantId } = useAuth();
|
||||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
|
||||||
const currentUserId = sub || user?.id || '';
|
const currentUserId = sub || user?.id || '';
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
@@ -241,7 +246,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||||
@@ -263,6 +267,37 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [usersDirectory]);
|
}, [usersDirectory]);
|
||||||
|
|
||||||
|
const modalExecutiveUsers = React.useMemo(() => {
|
||||||
|
const list: User[] = [...executiveAndManagerUsers];
|
||||||
|
const linkedUserId =
|
||||||
|
editingOrg?.userId ||
|
||||||
|
editingOrg?.UserId ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (linkedUserId) {
|
||||||
|
const linkedStr = String(linkedUserId).toLowerCase();
|
||||||
|
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||||
|
if (!alreadyIncluded) {
|
||||||
|
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||||
|
if (inDirectory) {
|
||||||
|
list.unshift(inDirectory);
|
||||||
|
} else {
|
||||||
|
const email = getUserEmail(linkedUserId);
|
||||||
|
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
|
||||||
|
list.unshift({
|
||||||
|
id: String(linkedUserId),
|
||||||
|
email: hasRealEmail ? email : String(linkedUserId),
|
||||||
|
role: 'Executive',
|
||||||
|
status: ActivityStatus.Active,
|
||||||
|
lastLoginAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
|
||||||
|
|
||||||
// Quick Add Executive User Subform state
|
// Quick Add Executive User Subform state
|
||||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||||
@@ -560,11 +595,23 @@ export const GovernancePage: React.FC = () => {
|
|||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
organisationUser: '',
|
organisationUser: '',
|
||||||
|
idNumber: '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedOrgUser = orgForm.watch('organisationUser');
|
||||||
|
const selectedExecutiveUser = React.useMemo(() => {
|
||||||
|
if (!watchedOrgUser) return null;
|
||||||
|
const str = String(watchedOrgUser).toLowerCase();
|
||||||
|
return (
|
||||||
|
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
|
||||||
|
|
||||||
const portfolioForm = useForm<PortfolioFormValues>({
|
const portfolioForm = useForm<PortfolioFormValues>({
|
||||||
resolver: zodResolver(portfolioSchema),
|
resolver: zodResolver(portfolioSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -580,7 +627,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -589,11 +636,20 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const orgMutation = useMutation({
|
const orgMutation = useMutation({
|
||||||
mutationFn: (data: OrganisationFormValues) => {
|
mutationFn: (data: OrganisationFormValues) => {
|
||||||
if (editingOrg) {
|
if (editingOrg) {
|
||||||
|
if (!data.idNumber || !data.idNumber.trim()) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Government ID number is required to assign this executive user.',
|
||||||
|
});
|
||||||
|
throw new Error('Executive user ID number is required when updating an organisation.');
|
||||||
|
}
|
||||||
|
|
||||||
return updateOrganisation({
|
return updateOrganisation({
|
||||||
organisationId: editingOrg.id,
|
organisationId: editingOrg.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
registrationNo: data.registrationNo,
|
registrationNo: data.registrationNo,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
idNumber: data.idNumber.trim(),
|
||||||
organisationUser: data.organisationUser,
|
organisationUser: data.organisationUser,
|
||||||
type: Number(data.type),
|
type: Number(data.type),
|
||||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||||
@@ -603,11 +659,13 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
registrationNo: data.registrationNo,
|
registrationNo: data.registrationNo,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
idNumber: data.idNumber?.trim() || null,
|
||||||
organisationUser: data.organisationUser,
|
organisationUser: data.organisationUser,
|
||||||
type: Number(data.type),
|
type: Number(data.type),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||||
setIsOrgModalOpen(false);
|
setIsOrgModalOpen(false);
|
||||||
@@ -639,6 +697,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||||
setIsPortfolioModalOpen(false);
|
setIsPortfolioModalOpen(false);
|
||||||
@@ -650,15 +709,43 @@ export const GovernancePage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const approveProgrammeMutation = useMutation({
|
||||||
|
mutationFn: async (pr: Programme) => {
|
||||||
|
if (isMaker(pr.createdBy)) {
|
||||||
|
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
|
||||||
|
}
|
||||||
|
return updateProgramme({
|
||||||
|
programmeId: pr.id,
|
||||||
|
name: pr.name || '',
|
||||||
|
portfolioId: Number(pr.portfolioId),
|
||||||
|
status: ApprovalStatus.Approved,
|
||||||
|
ownerId: pr.ownedBy,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||||
|
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toastError('Programme Approval Failed', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const programmeMutation = useMutation({
|
const programmeMutation = useMutation({
|
||||||
mutationFn: (data: ProgrammeFormValues) => {
|
mutationFn: (data: ProgrammeFormValues) => {
|
||||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||||
if (editingProgramme) {
|
if (editingProgramme) {
|
||||||
|
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||||
|
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||||
|
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||||
|
}
|
||||||
return updateProgramme({
|
return updateProgramme({
|
||||||
programmeId: editingProgramme.id,
|
programmeId: editingProgramme.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
portfolioId: Number(data.portfolioId),
|
portfolioId: Number(data.portfolioId),
|
||||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
status: targetStatus,
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -669,7 +756,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||||
setIsProgrammeModalOpen(false);
|
setIsProgrammeModalOpen(false);
|
||||||
setEditingProgramme(null);
|
setEditingProgramme(null);
|
||||||
@@ -688,8 +777,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
organisationUser: defaultUser?.id || '',
|
organisationUser: defaultUser?.id || '',
|
||||||
|
idNumber: defaultUser?.idNumber || '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
});
|
});
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
setQuickUserError(null);
|
setQuickUserError(null);
|
||||||
@@ -702,26 +792,51 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const targetUserId =
|
const targetUserId =
|
||||||
org.userId ||
|
org.userId ||
|
||||||
org.UserId ||
|
org.UserId ||
|
||||||
org.organisationUser ||
|
|
||||||
org.OrganisationUser ||
|
|
||||||
org.createdBy ||
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
const matchedUser = targetUserId
|
let selectedUserId = '';
|
||||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
let matchedUser: User | undefined;
|
||||||
: null;
|
if (targetUserId) {
|
||||||
|
const targetStr = String(targetUserId).toLowerCase();
|
||||||
|
matchedUser = usersDirectory.find(
|
||||||
|
(u) =>
|
||||||
|
(u.id && String(u.id).toLowerCase() === targetStr) ||
|
||||||
|
(u.email && u.email.toLowerCase() === targetStr)
|
||||||
|
);
|
||||||
|
|
||||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
if (matchedUser) {
|
||||||
|
selectedUserId = matchedUser.id;
|
||||||
|
if (matchedUser.email) {
|
||||||
|
registerUserEmail(matchedUser.id, matchedUser.email);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedUserId = String(targetUserId);
|
||||||
|
const email = getUserEmail(targetUserId);
|
||||||
|
if (email && email !== '—' && email.includes('@')) {
|
||||||
|
registerUserEmail(selectedUserId, email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
|
||||||
|
|
||||||
orgForm.reset({
|
orgForm.reset({
|
||||||
name: org.name || '',
|
name: org.name || '',
|
||||||
registrationNo: org.registrationNo || '',
|
registrationNo: org.registrationNo || '',
|
||||||
email: org.email || '',
|
email: org.email || '',
|
||||||
organisationUser: selectedUserId,
|
organisationUser: selectedUserId,
|
||||||
|
idNumber: resolvedIdNumber,
|
||||||
type: org.type || OrganisationType.Enterprise,
|
type: org.type || OrganisationType.Enterprise,
|
||||||
status: org.status ?? ActivityStatus.Active,
|
status: org.status ?? ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selectedUserId && !resolvedIdNumber) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
setQuickUserError(null);
|
setQuickUserError(null);
|
||||||
setIsOrgModalOpen(true);
|
setIsOrgModalOpen(true);
|
||||||
@@ -755,7 +870,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
programmeForm.reset({
|
programmeForm.reset({
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
@@ -833,6 +948,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||||
createdAt: (p) => p.createdAt,
|
createdAt: (p) => p.createdAt,
|
||||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||||
|
createdBy: (p) => getUserEmail(p.createdBy),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export Columns Definitions
|
// Export Columns Definitions
|
||||||
@@ -863,6 +979,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||||
},
|
},
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||||
];
|
];
|
||||||
@@ -881,6 +998,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1150,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||||
{org.email}
|
{org.email}
|
||||||
</span>
|
</span>
|
||||||
|
{org.userId && (
|
||||||
|
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
|
||||||
|
Exec: {getUserEmail(org.userId)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
@@ -1382,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
Owner Email
|
Owner Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
|
Created By
|
||||||
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
{loadingProgrammes ? (
|
{loadingProgrammes ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading programmes...
|
Loading programmes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedProgrammes.length === 0 ? (
|
) : sortedProgrammes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
No programmes found matching query.
|
No programmes found matching query.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1402,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
sortedProgrammes.map((pr) => {
|
sortedProgrammes.map((pr) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||||
|
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||||
return (
|
return (
|
||||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5">
|
<td className="py-3.5 px-5">
|
||||||
@@ -1420,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
||||||
{getUserEmail(pr.ownedBy)}
|
{getUserEmail(pr.ownedBy)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||||
|
{getUserEmail(pr.createdBy)}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||||
|
{isApproved ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
|
title="Approved programmes cannot be modified."
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>Approved</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditProgramme(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Edit Programme"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{canApproveRecord('governance', pr.createdBy) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve Programme"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1458,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<Scale className="w-3 h-3" />
|
<Scale className="w-3 h-3" />
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openEditProgramme(pr)}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3 h-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1611,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Linked Executive / Manager User *
|
Responsible Executive User *
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Designated Organisation User"
|
title="Executive / Organisation User"
|
||||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
@@ -1636,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
{...orgForm.register('organisationUser')}
|
{...orgForm.register('organisationUser', {
|
||||||
|
onChange: (e) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
orgForm.setValue('idNumber', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const str = String(selectedId).toLowerCase();
|
||||||
|
const matched =
|
||||||
|
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||||
|
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
|
||||||
|
|
||||||
|
if (matched?.idNumber && matched.idNumber.trim()) {
|
||||||
|
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
|
||||||
|
orgForm.clearErrors('idNumber');
|
||||||
|
} else {
|
||||||
|
orgForm.setValue('idNumber', '', { shouldValidate: true });
|
||||||
|
if (editingOrg) {
|
||||||
|
orgForm.setError('idNumber', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
>
|
>
|
||||||
<option value="">-- Select linked executive or manager account --</option>
|
<option value="">-- Select linked executive or manager account --</option>
|
||||||
{executiveAndManagerUsers.map((u) => (
|
{modalExecutiveUsers.map((u) => (
|
||||||
<option key={u.id} value={u.id}>
|
<option key={u.id} value={u.id}>
|
||||||
{u.email || u.id} ({u.role || 'Executive'})
|
{u.email || u.id} ({u.role || 'Executive'})
|
||||||
</option>
|
</option>
|
||||||
@@ -1654,7 +1839,59 @@ export const GovernancePage: React.FC = () => {
|
|||||||
No Executive or Manager user found. Create one below to continue.
|
No Executive or Manager user found. Create one below to continue.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing ID Number Alert */}
|
||||||
|
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||||
|
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-amber-200">
|
||||||
|
Missing Executive ID Number
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||||
|
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Executive User ID Number Input */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Executive ID Requirement"
|
||||||
|
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedExecutiveUser?.idNumber && (
|
||||||
|
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...orgForm.register('idNumber')}
|
||||||
|
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||||
|
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter 13-digit ID or Passport Number"
|
||||||
|
/>
|
||||||
|
{orgForm.formState.errors.idNumber && (
|
||||||
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline Collapsible Subform to Create Executive User */}
|
{/* Inline Collapsible Subform to Create Executive User */}
|
||||||
@@ -2004,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||||
<div
|
<div
|
||||||
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
title="Approved programmes are immutable."
|
title="Approved programmes cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
@@ -2460,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
|
||||||
<span className="text-blue-400 font-mono text-[11px] truncate">
|
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||||
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
|
||||||
|
<span className="text-slate-300 font-mono text-[11px] truncate">
|
||||||
|
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2717,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
</span>
|
</span>
|
||||||
|
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||||
|
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Approve Programme</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core Details Grid: Owner & Creation Date */}
|
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
{/* Creation Date */}
|
{/* Creation Date */}
|
||||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
@@ -2745,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Maker */}
|
||||||
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||||
|
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||||
|
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics: Total Rules and Total Instructions */}
|
{/* Metrics: Total Rules and Total Instructions */}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Budget,
|
Budget,
|
||||||
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
||||||
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
|
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
|
||||||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateAwardMutation = useMutation({
|
const updateAwardMutation = useMutation({
|
||||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success('Award Updated', 'Award details saved successfully.');
|
success('Award Updated', 'Award details saved successfully.');
|
||||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const budgetMutation = useMutation({
|
const budgetMutation = useMutation({
|
||||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget line established', 'Budget line created against project');
|
success('Budget line established', 'Budget line created against project');
|
||||||
setIsBudgetModalOpen(false);
|
setIsBudgetModalOpen(false);
|
||||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateBudgetMutation = useMutation({
|
const updateBudgetMutation = useMutation({
|
||||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||||
setIsEditBudgetModalOpen(false);
|
setIsEditBudgetModalOpen(false);
|
||||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
success(
|
success(
|
||||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success(
|
success(
|
||||||
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleAwardSort,
|
handleSort: handleAwardSort,
|
||||||
} = useTableSort(filteredAwards, null, null, {
|
} = useTableSort(filteredAwards, null, null, {
|
||||||
referenceNo: (a) => a.referenceNo,
|
referenceNo: (a) => a.referenceNo,
|
||||||
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
|
organisationId: (a) => {
|
||||||
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
|
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
|
||||||
|
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
|
||||||
|
},
|
||||||
|
programmeId: (a) => {
|
||||||
|
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
|
||||||
|
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (a) => a.amount,
|
amount: (a) => a.amount,
|
||||||
status: (a) => getApprovalStatusBadge(a.status).label,
|
status: (a) => getApprovalStatusBadge(a.status).label,
|
||||||
createdAt: (a) => a.createdAt,
|
createdAt: (a) => a.createdAt,
|
||||||
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleBudgetSort,
|
handleSort: handleBudgetSort,
|
||||||
} = useTableSort(filteredBudgets, null, null, {
|
} = useTableSort(filteredBudgets, null, null, {
|
||||||
name: (b) => b.name,
|
name: (b) => b.name,
|
||||||
projectId: (b) => b.projectId,
|
projectId: (b) => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||||
|
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (b) => b.amount,
|
amount: (b) => b.amount,
|
||||||
createdAt: (b) => b.createdAt,
|
createdAt: (b) => b.createdAt,
|
||||||
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
||||||
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleDisbSort,
|
handleSort: handleDisbSort,
|
||||||
} = useTableSort(filteredDisbursements, null, null, {
|
} = useTableSort(filteredDisbursements, null, null, {
|
||||||
id: (d) => d.id,
|
id: (d) => d.id,
|
||||||
projectId: (d) => d.projectId,
|
projectId: (d) => {
|
||||||
milestoneId: (d) => d.milestoneId || 0,
|
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||||
|
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
|
||||||
|
},
|
||||||
|
milestoneId: (d) => {
|
||||||
|
if (!d.milestoneId) return 'Advance Tranche';
|
||||||
|
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||||
|
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
|
||||||
|
},
|
||||||
amount: (d) => d.amount,
|
amount: (d) => d.amount,
|
||||||
paymentReference: (d) => d.paymentReference || '',
|
paymentReference: (d) => d.paymentReference || '',
|
||||||
status: (d) => getApprovalStatusBadge(d.status).label,
|
status: (d) => getApprovalStatusBadge(d.status).label,
|
||||||
@@ -826,8 +848,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchAwards();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
|
await refetchAwards();
|
||||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingAwards}
|
disabled={isFetchingAwards}
|
||||||
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
||||||
{awd.organisationName || `Org #${awd.organisationId}`}
|
{(() => {
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
|
||||||
|
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-white block font-medium">{orgName}</span>
|
||||||
|
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400">
|
<td className="py-3.5 px-4 text-slate-400">
|
||||||
{awd.programmeName || `Programme #${awd.programmeId}`}
|
{(() => {
|
||||||
|
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
|
||||||
|
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-200 block">{progName}</span>
|
||||||
|
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(awd.amount)}
|
{formatZAR(awd.amount)}
|
||||||
@@ -919,10 +961,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved award is immutable. Details cannot be modified."
|
title="Approved award details cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -973,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchBudgets();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
|
await refetchBudgets();
|
||||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingBudgets}
|
disabled={isFetchingBudgets}
|
||||||
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Budget Line Category
|
Budget Line Category
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
||||||
Approved Amount (ZAR)
|
Approved Amount (ZAR)
|
||||||
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="font-semibold text-white block">{b.name}</span>
|
<span className="font-semibold text-white block">{b.name}</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
|
<td className="py-3.5 px-4 text-slate-200">
|
||||||
|
{(() => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(b.projectId));
|
||||||
|
const projName = proj?.name || `Project #${b.projectId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white block">{projName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
{formatZAR(b.amount)}
|
{formatZAR(b.amount)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1059,10 +1114,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved budget is immutable. Status cannot be modified."
|
title="Approved budget status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1133,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchDisbursements();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
|
await refetchDisbursements();
|
||||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingDisbursements}
|
disabled={isFetchingDisbursements}
|
||||||
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Disbursement ID
|
Disbursement ID
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Milestone Association
|
Milestone Association
|
||||||
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||||||
DSB-{d.id}
|
DSB-{d.id}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
|
<td className="py-3.5 px-4 text-slate-200">
|
||||||
|
{(() => {
|
||||||
|
const proj = projects.find((p) => String(p.id) === String(d.projectId));
|
||||||
|
const projName = proj?.name || `Project #${d.projectId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white block">{projName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
<td className="py-3.5 px-4 text-slate-400 font-medium">
|
||||||
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
|
{(() => {
|
||||||
|
if (!d.milestoneId) {
|
||||||
|
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
|
||||||
|
}
|
||||||
|
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
|
||||||
|
const msName = ms?.name || `Milestone #${d.milestoneId}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block">{msName}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(d.amount)}
|
{formatZAR(d.amount)}
|
||||||
@@ -1549,7 +1629,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||||
if (!editingAward) return;
|
if (!editingAward) return;
|
||||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selectedAwardStatus === ApprovalStatus.Approved &&
|
||||||
|
!canApproveRecord('grant_financials', editingAward.createdBy)
|
||||||
|
) {
|
||||||
|
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateAwardMutation.mutate({
|
updateAwardMutation.mutate({
|
||||||
@@ -1630,7 +1717,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||||
<option value={ApprovalStatus.Approved}>Approved & Activated</option>
|
{(() => {
|
||||||
|
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
|
||||||
|
const isSelf = isMaker(editingAward?.createdBy);
|
||||||
|
let reason = '';
|
||||||
|
if (!canApprove('grant_financials')) {
|
||||||
|
reason = ' (Requires Update Permission)';
|
||||||
|
} else if (isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveAwd}>
|
||||||
|
Approved & Activated {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
@@ -1783,7 +1884,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -149,7 +148,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||||
setSelectedMessage(folderMessages[0]);
|
const first = folderMessages[0];
|
||||||
|
setSelectedMessage(first);
|
||||||
|
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
|
||||||
|
markAsRead(first.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [targetIdParam, folderMessages, activeFolder]);
|
}, [targetIdParam, folderMessages, activeFolder]);
|
||||||
|
|
||||||
@@ -666,6 +669,22 @@ export const InboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteMessage(item.id);
|
||||||
|
if (selectedMessage?.id === item.id) {
|
||||||
|
setSelectedMessage(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
|
||||||
|
title="Delete message (Archive)"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
{formatDate(item.createdAt)}
|
{formatDate(item.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
recordIndicatorActual,
|
recordIndicatorActual,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
getActivities,
|
getActivities,
|
||||||
|
getDisbursements,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
queryFn: () => getBudgets(1, 100),
|
queryFn: () => getBudgets(1, 100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allDisbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements'],
|
||||||
|
queryFn: () => getDisbursements(1, 200),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [allBudgets]);
|
}, [allBudgets]);
|
||||||
|
|
||||||
|
// Disbursed capital per project
|
||||||
|
const disbursedByProject = useMemo(() => {
|
||||||
|
const map = new Map<string | number, number>();
|
||||||
|
allDisbursements.forEach((d) => {
|
||||||
|
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
|
||||||
|
const current = map.get(d.projectId) || 0;
|
||||||
|
map.set(d.projectId, current + (Number(d.amount) || 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allDisbursements]);
|
||||||
|
|
||||||
// Filtered Projects
|
// Filtered Projects
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projects.filter((p) => {
|
return projects.filter((p) => {
|
||||||
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Project Variance (ZAR)',
|
||||||
|
key: 'id',
|
||||||
|
format: (_, p) => {
|
||||||
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
const pVariance = approvedBudget - projectValue;
|
||||||
|
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
|
||||||
|
|
||||||
// Table Sorting
|
// Table Sorting
|
||||||
const customExtractors = {
|
const customExtractors = {
|
||||||
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
if (risk.isAtRisk) return 2;
|
if (risk.isAtRisk) return 2;
|
||||||
return 3;
|
return 3;
|
||||||
},
|
},
|
||||||
|
variance: (p: Project) => {
|
||||||
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
return approvedBudget - projectValue;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||||||
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
|
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<span>Project Variance</span>
|
<span>Project Variance</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Project Variance"
|
title="Project Variance"
|
||||||
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
|
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
const pActs = allProjectActivities.get(p.id) || [];
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
const pVariance = approvedBudget - pActsTotal;
|
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
|
||||||
|
const projectValue = pActsTotal - pDisbursedTotal;
|
||||||
|
const pVariance = approvedBudget - projectValue;
|
||||||
const isPos = pVariance >= 0;
|
const isPos = pVariance >= 0;
|
||||||
return (
|
return (
|
||||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||||
}`}
|
}`}
|
||||||
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||||
>
|
>
|
||||||
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
getOrganisations,
|
getOrganisations,
|
||||||
getAwards,
|
getAwards,
|
||||||
getContracts,
|
getContracts,
|
||||||
|
getProjects,
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
notifyInviteApproved,
|
notifyInviteApproved,
|
||||||
@@ -79,6 +80,8 @@ import {
|
|||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
|
ApprovalStatus,
|
||||||
|
ContractStatus,
|
||||||
User,
|
User,
|
||||||
Invite,
|
Invite,
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
@@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||||
|
|
||||||
const { canView, canAdd, canExport } = useRbac();
|
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||||
const canViewInvites = canView('settings_invites');
|
const canViewInvites = canView('settings_invites');
|
||||||
const canViewNotifications = canView('settings_notifications');
|
const canViewNotifications = canView('settings_notifications');
|
||||||
@@ -152,7 +155,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -241,6 +243,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
||||||
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
||||||
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
||||||
|
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
|
||||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||||
const [inviteMessage, setInviteMessage] = useState('');
|
const [inviteMessage, setInviteMessage] = useState('');
|
||||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||||
@@ -253,6 +256,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||||
|
if (isMaker(inv.createdBy)) {
|
||||||
|
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||||
@@ -270,32 +277,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Invites filtering & search
|
|
||||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
|
||||||
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: invitesList = [],
|
|
||||||
isLoading: loadingInvites,
|
|
||||||
isFetching: isFetchingInvites,
|
|
||||||
refetch: refetchInvites,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['invites'],
|
|
||||||
queryFn: () => getInvites(1, 100),
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredInvites = useMemo(() => {
|
|
||||||
return invitesList.filter((inv) => {
|
|
||||||
const matchSearch =
|
|
||||||
!inviteSearchTerm.trim() ||
|
|
||||||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
|
||||||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
|
|
||||||
const matchPlatform =
|
|
||||||
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
|
|
||||||
return matchSearch && matchPlatform;
|
|
||||||
});
|
|
||||||
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
|
|
||||||
|
|
||||||
const { data: organisations = [] } = useQuery({
|
const { data: organisations = [] } = useQuery({
|
||||||
queryKey: ['organisations'],
|
queryKey: ['organisations'],
|
||||||
queryFn: () => getOrganisations(1, 50),
|
queryFn: () => getOrganisations(1, 50),
|
||||||
@@ -311,6 +292,104 @@ export const SettingsPage: React.FC = () => {
|
|||||||
queryFn: () => getContracts(1, 50),
|
queryFn: () => getContracts(1, 50),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: projects = [] } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => getProjects(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
|
||||||
|
const eligibleAwards = useMemo(() => {
|
||||||
|
return awards.filter((aw) => {
|
||||||
|
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
|
||||||
|
if (!isApprovedAward) return false;
|
||||||
|
const hasApprovedContract = contracts.some(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(aw.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
return hasApprovedContract;
|
||||||
|
});
|
||||||
|
}, [awards, contracts]);
|
||||||
|
|
||||||
|
const handleSelectAward = (awardId: string | number) => {
|
||||||
|
setInviteAwardId(awardId);
|
||||||
|
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
|
||||||
|
if (selected) {
|
||||||
|
setInvitedOrgId(selected.organisationId);
|
||||||
|
const matchingContract = contracts.find(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(selected.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
if (matchingContract) {
|
||||||
|
setInviteContractId(matchingContract.id);
|
||||||
|
}
|
||||||
|
setInviteProjectId(selected.projectId || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSendInviteModalOpen) {
|
||||||
|
if (eligibleAwards.length > 0) {
|
||||||
|
const currentAward =
|
||||||
|
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
|
||||||
|
setInviteAwardId(currentAward.id);
|
||||||
|
setInvitedOrgId(currentAward.organisationId);
|
||||||
|
const matchingContract = contracts.find(
|
||||||
|
(c) =>
|
||||||
|
String(c.awardId) === String(currentAward.id) &&
|
||||||
|
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
|
||||||
|
);
|
||||||
|
if (matchingContract) {
|
||||||
|
setInviteContractId(matchingContract.id);
|
||||||
|
}
|
||||||
|
setInviteProjectId(currentAward.projectId || '');
|
||||||
|
} else {
|
||||||
|
setInviteAwardId('');
|
||||||
|
setInvitedOrgId('');
|
||||||
|
setInviteContractId('');
|
||||||
|
setInviteProjectId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
|
||||||
|
|
||||||
|
// Invites filtering & search
|
||||||
|
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||||
|
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: invitesList = [],
|
||||||
|
isLoading: loadingInvites,
|
||||||
|
isFetching: isFetchingInvites,
|
||||||
|
refetch: refetchInvites,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['invites'],
|
||||||
|
queryFn: () => getInvites(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
|
||||||
|
if (!inviteId) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
|
||||||
|
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
|
||||||
|
if (!userIdOrUuid) return '—';
|
||||||
|
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
|
||||||
|
const email = u?.email || getUserEmail(userIdOrUuid);
|
||||||
|
return email && email !== '—' ? email : String(userIdOrUuid);
|
||||||
|
};
|
||||||
|
|
||||||
const sendInviteMutation = useMutation({
|
const sendInviteMutation = useMutation({
|
||||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
@@ -320,8 +399,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setInviteRecipientUuid('');
|
setInviteRecipientUuid('');
|
||||||
setInviteMessage('');
|
setInviteMessage('');
|
||||||
|
|
||||||
if (inviteAutoApprove) {
|
|
||||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||||
|
storeInvitePlatform(invId, variables.platform);
|
||||||
|
|
||||||
|
if (inviteAutoApprove) {
|
||||||
markInviteAsApprovedInStorage(invId, currentTenantId);
|
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||||
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||||
|
|
||||||
@@ -333,6 +414,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
organisationId: variables.organisationId,
|
organisationId: variables.organisationId,
|
||||||
awardId: variables.awardId,
|
awardId: variables.awardId,
|
||||||
contractId: variables.contractId,
|
contractId: variables.contractId,
|
||||||
|
projectId: variables.projectId,
|
||||||
message: variables.message,
|
message: variables.message,
|
||||||
},
|
},
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -418,6 +500,38 @@ export const SettingsPage: React.FC = () => {
|
|||||||
handleSort: handleNotifSort,
|
handleSort: handleNotifSort,
|
||||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
||||||
|
|
||||||
|
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
|
||||||
|
if (inv.platform !== undefined && inv.platform !== null) {
|
||||||
|
return Number(inv.platform) as NotificationPlatform;
|
||||||
|
}
|
||||||
|
const stored = getStoredInvitePlatform(inv.id);
|
||||||
|
if (stored !== undefined) return stored;
|
||||||
|
|
||||||
|
const linkedNotif = fetchedNotifications.find(
|
||||||
|
(n) =>
|
||||||
|
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
|
||||||
|
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
|
||||||
|
);
|
||||||
|
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
|
||||||
|
return Number(linkedNotif.platform) as NotificationPlatform;
|
||||||
|
}
|
||||||
|
return NotificationPlatform.Email;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredInvites = useMemo(() => {
|
||||||
|
return invitesList.filter((inv) => {
|
||||||
|
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
|
||||||
|
const matchSearch =
|
||||||
|
!inviteSearchTerm.trim() ||
|
||||||
|
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
|
||||||
|
recipientEmail.includes(inviteSearchTerm.toLowerCase());
|
||||||
|
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||||
|
const matchPlatform =
|
||||||
|
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
|
||||||
|
return matchSearch && matchPlatform;
|
||||||
|
});
|
||||||
|
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. Audit Log Ledger State
|
// 4. Audit Log Ledger State
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -486,9 +600,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
header: 'Changes / Arguments',
|
header: 'Changes / Arguments',
|
||||||
key: 'changes',
|
key: 'changes',
|
||||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
format: (val) => {
|
||||||
|
if (!val) return '—';
|
||||||
|
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||||
|
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Error Details',
|
||||||
|
key: 'error',
|
||||||
|
format: (val) => {
|
||||||
|
if (!val) return '—';
|
||||||
|
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const uniqueAuditEntities = Array.from(
|
const uniqueAuditEntities = Array.from(
|
||||||
@@ -558,13 +685,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const inviteExportColumns: ExportColumn<Invite>[] = [
|
const inviteExportColumns: ExportColumn<Invite>[] = [
|
||||||
{ header: 'Recipient (UUID)', key: 'recipient' },
|
{
|
||||||
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
header: 'Recipient Email',
|
||||||
{ header: 'Invitation Message', key: 'message' },
|
key: 'recipient',
|
||||||
|
format: (val) => resolveUserEmail(val),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Channel / Platform',
|
||||||
|
key: 'platform',
|
||||||
|
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Target Organisation / Grant',
|
header: 'Target Organisation / Grant',
|
||||||
key: 'invitedOrganisationId',
|
key: 'invitedOrganisationId',
|
||||||
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
|
format: (val, inv) => {
|
||||||
|
const orgId = val || inv.organisationId;
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
|
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||||
|
return `${orgName} • Award #${inv.awardId || '—'}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
||||||
];
|
];
|
||||||
@@ -590,7 +729,15 @@ export const SettingsPage: React.FC = () => {
|
|||||||
sortColumn: inviteSortCol,
|
sortColumn: inviteSortCol,
|
||||||
sortDirection: inviteSortDir,
|
sortDirection: inviteSortDir,
|
||||||
handleSort: handleInviteSort,
|
handleSort: handleInviteSort,
|
||||||
} = useTableSort(filteredInvites, 'createdAt', 'desc');
|
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
|
||||||
|
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
|
||||||
|
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||||
|
invitedOrganisationId: (inv: Invite) => {
|
||||||
|
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
|
return org?.name || `Organisation #${orgId || ''}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const getActionBadge = (action?: string | null) => {
|
const getActionBadge = (action?: string | null) => {
|
||||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||||
@@ -1181,14 +1328,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||||
Recipient (UUID)
|
Recipient Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Channel
|
Channel
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
|
||||||
Message
|
|
||||||
</SortableTh>
|
|
||||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Target Org / Grant
|
Target Org / Grant
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
@@ -1206,13 +1350,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingInvites ? (
|
{loadingInvites ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
Loading invitations ledger...
|
Loading invitations ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedInvites.length === 0 ? (
|
) : sortedInvites.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1222,22 +1366,34 @@ export const SettingsPage: React.FC = () => {
|
|||||||
approvedInviteIds.includes(String(inv.id)) ||
|
approvedInviteIds.includes(String(inv.id)) ||
|
||||||
approvedInviteIds.includes(`inv_${idx}`) ||
|
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||||
approvedInviteIds.includes(String(idx));
|
approvedInviteIds.includes(String(idx));
|
||||||
|
const resolvedPlatform = resolveInvitePlatform(inv);
|
||||||
|
const userEmail = resolveUserEmail(inv.recipient);
|
||||||
|
const orgId = inv.invitedOrganisationId || inv.organisationId;
|
||||||
|
const org = organisations.find((o) => String(o.id) === String(orgId));
|
||||||
|
const orgName = org?.name || `Organisation #${orgId || '—'}`;
|
||||||
|
const isCreator = isMaker(inv.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
<td className="py-3.5 px-5 text-slate-200 text-xs">
|
||||||
|
<div className="font-medium text-white flex items-center gap-1.5">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
|
<span className="truncate">{userEmail}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
|
||||||
{inv.recipient}
|
{inv.recipient}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||||
{getNotificationPlatformLabel(inv.platform)}
|
{getNotificationPlatformLabel(resolvedPlatform)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
<td className="py-3.5 px-4 text-slate-300 text-xs">
|
||||||
{inv.message}
|
<div className="font-medium text-white">{orgName}</div>
|
||||||
</td>
|
<div className="text-slate-400 text-[11px] mt-0.5">
|
||||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
Award #{inv.awardId || '—'}
|
||||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
{isApproved ? (
|
{isApproved ? (
|
||||||
@@ -1261,7 +1417,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
Partner Notified
|
Partner Notified
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : canApproveRecord('settings_invites', inv.createdBy) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleApproveInvite(inv, idx)}
|
onClick={() => handleApproveInvite(inv, idx)}
|
||||||
@@ -1271,6 +1427,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : isCreator ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
|
||||||
|
title="Maker-Checker: You created this invite and cannot approve it"
|
||||||
|
>
|
||||||
|
Maker (Creator)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-500">
|
||||||
|
Pending Review
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2491,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!inviteAwardId) {
|
||||||
|
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!inviteRecipientUuid || !inviteMessage) {
|
if (!inviteRecipientUuid || !inviteMessage) {
|
||||||
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
|
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendInviteMutation.mutate({
|
sendInviteMutation.mutate({
|
||||||
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
||||||
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
||||||
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
|
awardId: inviteAwardId,
|
||||||
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
|
contractId: inviteContractId,
|
||||||
|
projectId: inviteProjectId || 0,
|
||||||
recipient: inviteRecipientUuid,
|
recipient: inviteRecipientUuid,
|
||||||
message: inviteMessage,
|
message: inviteMessage,
|
||||||
platform: invitePlatform,
|
platform: invitePlatform,
|
||||||
@@ -2507,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
|
{eligibleAwards.length === 0 ? (
|
||||||
|
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
|
||||||
|
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
|
||||||
|
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
|
||||||
|
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved & Active</strong>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Recipient User UUID *
|
Recipient User *
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Recipient UUID"
|
title="Recipient Directory Identifier"
|
||||||
content="Unique directory user identifier or email for targeted delivery."
|
content="Select a registered recipient user from directory or enter their unique user UUID."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{usersDirectory.length > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{usersDirectory.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) setInviteRecipientUuid(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose from Registered Users --</option>
|
||||||
|
{usersDirectory.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteRecipientUuid}
|
value={inviteRecipientUuid}
|
||||||
@@ -2529,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Approved Award *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Approved Award Required"
|
||||||
|
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={inviteAwardId}
|
||||||
|
onChange={(e) => handleSelectAward(e.target.value)}
|
||||||
|
disabled={eligibleAwards.length === 0}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{eligibleAwards.length === 0 ? (
|
||||||
|
<option value="">No approved awards available</option>
|
||||||
|
) : (
|
||||||
|
eligibleAwards.map((aw) => {
|
||||||
|
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
|
||||||
|
return (
|
||||||
|
<option key={aw.id} value={aw.id}>
|
||||||
|
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Approved Contract *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Associated Approved Contract"
|
||||||
|
content="The active/approved contract linked to the selected award."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Target Organisation (Recipient Org)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(() => {
|
||||||
|
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
|
||||||
|
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
|
||||||
|
})()}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Associated Project
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(() => {
|
||||||
|
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
|
||||||
|
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
|
||||||
|
})()}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
@@ -2558,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Originating Organisation
|
Originating Authority
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Inviting Authority"
|
title="Inviting Authority"
|
||||||
@@ -2581,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
|
||||||
Associated Award
|
|
||||||
</label>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Linked Grant Award"
|
|
||||||
content="Optional award attachment to link recipient permissions directly to grant administration."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={inviteAwardId}
|
|
||||||
onChange={(e) => setInviteAwardId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
|
||||||
>
|
|
||||||
{awards.map((aw) => (
|
|
||||||
<option key={aw.id} value={aw.id}>
|
|
||||||
Award #{aw.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
|
||||||
Associated Contract
|
|
||||||
</label>
|
|
||||||
<HelpTooltip
|
|
||||||
title="Linked Contract"
|
|
||||||
content="Optional bilateral agreement linking the recipient to contract execution."
|
|
||||||
size="sm"
|
|
||||||
placement="top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={inviteContractId}
|
|
||||||
onChange={(e) => setInviteContractId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
|
|
||||||
>
|
|
||||||
{contracts.map((ctr) => (
|
|
||||||
<option key={ctr.id} value={ctr.id}>
|
|
||||||
Contract #{ctr.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
@@ -2681,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sendInviteMutation.isPending}
|
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
|
||||||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
title="Approved site visits are immutable."
|
title="Approved site visits are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Verified</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
|||||||
} = useQuery<TenantOrganisation[]>({
|
} = useQuery<TenantOrganisation[]>({
|
||||||
queryKey: ['tenant-organisations', tenantId],
|
queryKey: ['tenant-organisations', tenantId],
|
||||||
queryFn: () => getTenantOrganisations(tenantId),
|
queryFn: () => getTenantOrganisations(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch tenant users directory to resolve linked user email per organisation
|
// Fetch tenant users directory to resolve linked user email per organisation
|
||||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
|||||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { canAdd } = useRbac();
|
const { canAdd } = useRbac();
|
||||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
} = useQuery<Tenant[]>({
|
} = useQuery<Tenant[]>({
|
||||||
queryKey: ['tenants-list'],
|
queryKey: ['tenants-list'],
|
||||||
queryFn: () => getTenants(),
|
queryFn: () => getTenants(),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
registrationNo: payload.registrationNo,
|
registrationNo: payload.registrationNo,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
idNumber: payload.idNumber,
|
idNumber: payload.idNumber || null,
|
||||||
organisationUser: payload.organisationUser,
|
organisationUser: payload.organisationUser,
|
||||||
type: Number(payload.type),
|
type: Number(payload.type),
|
||||||
status: orgStatus,
|
status: orgStatus,
|
||||||
@@ -442,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
|
||||||
|
|
||||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
export const getStoredInstructions = (): Instrument[] => [];
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Instrument[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored instruction duplicates', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredInstructions = (): Instrument[] => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const current = getStoredInstructions();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(i) => String(i.id) === String(inst.id) ||
|
|
||||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(inst);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored instruction', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||||
let apiItems: Instrument[] = [];
|
|
||||||
let apiSucceeded = false;
|
|
||||||
try {
|
try {
|
||||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||||
if (Array.isArray(res.data)) {
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
apiItems = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredInstructionDuplicates(apiItems);
|
|
||||||
return apiItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local items only when API failed
|
|
||||||
const localItems = getStoredInstructions().filter(
|
|
||||||
(item) => String(item.programmeId) === String(programmeId)
|
|
||||||
);
|
|
||||||
return localItems;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInstructions = getInstruments;
|
export const getInstructions = getInstruments;
|
||||||
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
|||||||
}
|
}
|
||||||
|
|
||||||
const map = new Map<string, Instrument>();
|
const map = new Map<string, Instrument>();
|
||||||
let hasApiData = false;
|
|
||||||
|
|
||||||
if (progList && progList.length > 0) {
|
if (progList && progList.length > 0) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
progList.map((pr) => getInstruments(pr.id))
|
progList.map((pr) => getInstruments(pr.id))
|
||||||
);
|
);
|
||||||
results.forEach((res) => {
|
results.forEach((res) => {
|
||||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||||
hasApiData = true;
|
|
||||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
if (hasApiData || map.size > 0) {
|
|
||||||
const allApiInstruments = Array.from(map.values());
|
|
||||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
|
||||||
return allApiInstruments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getStoredInstructions();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInstructions = getAllInstruments;
|
export const getAllInstructions = getAllInstruments;
|
||||||
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredInstructionDuplicates([res.data]);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackInstruction: Instrument = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredInstruction(fallbackInstruction);
|
|
||||||
return fallbackInstruction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createInstruction = createInstrument;
|
export const createInstruction = createInstrument;
|
||||||
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||||
if (res.data && res.data.id) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[updateInstrument] API update failed:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
|
||||||
const updatedInstrument: Instrument = {
|
|
||||||
id: targetId!,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
code: payload.code || existing?.code || null,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
createdBy: existing?.createdBy || 'system',
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: payload.userId || 'system',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
saveStoredInstruction(updatedInstrument);
|
|
||||||
}
|
|
||||||
if (isApprovedOrActiveStatus(payload.status)) {
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
recordType: 'Funding Instrument',
|
recordType: 'Funding Instrument',
|
||||||
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
title: payload.name,
|
title: payload.name,
|
||||||
}).catch(console.warn);
|
}).catch(console.warn);
|
||||||
}
|
}
|
||||||
return updatedInstrument;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateInstruction = updateInstrument;
|
export const updateInstruction = updateInstrument;
|
||||||
@@ -1359,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
|||||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||||
try {
|
try {
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
|
||||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||||
try {
|
try {
|
||||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||||
return rawRes.data || [];
|
return rawRes.data || [];
|
||||||
} catch (fallbackErr: any) {
|
} catch (fallbackErr: any) {
|
||||||
if (fallbackErr?.response?.status === 404) {
|
if (fallbackErr?.response?.status === 404) {
|
||||||
@@ -1644,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
|||||||
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||||
awardId: toSafeInt64String(payload.awardId),
|
awardId: toSafeInt64String(payload.awardId),
|
||||||
contractId: toSafeInt64String(payload.contractId),
|
contractId: toSafeInt64String(payload.contractId),
|
||||||
|
projectId: toSafeInt64String(payload.projectId || 0),
|
||||||
recipient: payload.recipient,
|
recipient: payload.recipient,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||||
@@ -1731,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
|||||||
subject: payload.subject || null,
|
subject: payload.subject || null,
|
||||||
destination: payload.destination || 'In-App User Inbox',
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
status: payload.status !== undefined ? payload.status : undefined,
|
status: payload.status !== undefined
|
||||||
|
? payload.status
|
||||||
|
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1753,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||||
|
await updateNotification({
|
||||||
|
notificationId: toIntegerId(notificationId),
|
||||||
|
status: NotificationStatus.Archived,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 9. Governance: Tenants APIs
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getTenants = async (): Promise<Tenant[]> => {
|
export const getTenants = async (): Promise<Tenant[]> => {
|
||||||
@@ -2246,76 +2126,21 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
|
||||||
|
|
||||||
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
export const getStoredRules = (): Rule[] => [];
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Rule[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored rule duplicates', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredRules = (): Rule[] => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredRule = (rule: Rule): void => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const current = getStoredRules();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(r) => String(r.id) === String(rule.id) ||
|
|
||||||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(rule);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored rule', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 15. Execution: Programme Rules APIs
|
// 15. Execution: Programme Rules APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||||
try {
|
|
||||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
|
||||||
if (localRule) return localRule;
|
|
||||||
throw new Error('Rule not found');
|
|
||||||
}
|
|
||||||
throw new Error('Rule not found');
|
throw new Error('Rule not found');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2332,38 +2157,16 @@ export const getRules = async (
|
|||||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||||
programmeId?: string | number
|
programmeId?: string | number
|
||||||
): Promise<Rule[]> => {
|
): Promise<Rule[]> => {
|
||||||
let apiRules: Rule[] = [];
|
|
||||||
let apiSucceeded = false;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||||
page: pagination.page || 1,
|
page: pagination.page || 1,
|
||||||
pageSize: pagination.pageSize || 100,
|
pageSize: pagination.pageSize || 100,
|
||||||
});
|
});
|
||||||
if (Array.isArray(res.data)) {
|
let result = Array.isArray(res.data) ? res.data : [];
|
||||||
apiRules = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[getRules] Error fetching from backend API:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredRuleDuplicates(apiRules);
|
|
||||||
let result = apiRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local storage only if API call failed
|
|
||||||
const localRules = getStoredRules();
|
|
||||||
let combined = localRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
||||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
|
||||||
}
|
|
||||||
return combined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||||
@@ -2375,44 +2178,12 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackRule: Rule = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(fallbackRule);
|
|
||||||
return fallbackRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||||
const validUserId = toValidUuid(payload.userId);
|
const validUserId = toValidUuid(payload.userId);
|
||||||
try {
|
|
||||||
await apiClient.put('/v1/api/execution/rules', {
|
await apiClient.put('/v1/api/execution/rules', {
|
||||||
ruleId: toIntegerId(payload.ruleId),
|
ruleId: toIntegerId(payload.ruleId),
|
||||||
userId: validUserId,
|
userId: validUserId,
|
||||||
@@ -2422,23 +2193,6 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
|||||||
version: Number(payload.version || 1),
|
version: Number(payload.version || 1),
|
||||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
console.warn('[updateRule] API update failed:', err);
|
|
||||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
|
||||||
const updatedRule: Rule = {
|
|
||||||
id: payload.ruleId,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
version: Number(payload.version || 1),
|
|
||||||
createdBy: existing?.createdBy || validUserId,
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: validUserId,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(updatedRule);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; version=1',
|
'Content-Type': 'application/json; version=1',
|
||||||
Accept: 'application/json; version=1',
|
Accept: 'application/json; version=1',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
@@ -81,36 +83,18 @@ apiClient.interceptors.request.use(
|
|||||||
config.url?.includes('amazonaws.com') ||
|
config.url?.includes('amazonaws.com') ||
|
||||||
config.url?.includes('garage');
|
config.url?.includes('garage');
|
||||||
|
|
||||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||||
|
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||||
const headers = config.headers || {};
|
|
||||||
const hasNoCacheHeader =
|
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
// Explicit freshness requested: attach fresh timestamp
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
if (config.params instanceof URLSearchParams) {
|
||||||
config.params.set('_t', String(Date.now()));
|
config.params.set('_t', String(Date.now()));
|
||||||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
} else {
|
} else {
|
||||||
config.params = config.params || {};
|
config.params = config.params || {};
|
||||||
config.params._t = Date.now();
|
config.params._t = Date.now();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Standard GET: attach current mutation epoch
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
config.params._v = getMutationEpoch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User Sync & Tenant Lifecycle Guard:
|
// User Sync & Tenant Lifecycle Guard:
|
||||||
// All API calls require an authenticated and enabled user in the backend system.
|
// All API calls require an authenticated and enabled user in the backend system.
|
||||||
|
|||||||
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
|
|||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedTenantLockout: {
|
|
||||||
key: string;
|
|
||||||
result: TenantLockoutResult;
|
|
||||||
timestamp: number;
|
|
||||||
} | null = null;
|
|
||||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
|
||||||
|
|
||||||
export function clearTenantLockoutCache(): void {
|
export function clearTenantLockoutCache(): void {
|
||||||
cachedTenantLockout = null;
|
// Caching removed to guarantee fresh tenant state on every verification
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStatusActive(status: any): boolean {
|
export function isStatusActive(status: any): boolean {
|
||||||
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
|
|||||||
candidateId = tenantClaim;
|
candidateId = tenantClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
// Fresh check: query endpoint directly without cache
|
||||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
|
||||||
if (
|
|
||||||
cachedTenantLockout.result.isActive &&
|
|
||||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
|
||||||
) {
|
|
||||||
return cachedTenantLockout.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenantRes: Tenant | null = null;
|
let tenantRes: Tenant | null = null;
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
tenant: tenantObj,
|
tenant: tenantObj,
|
||||||
};
|
};
|
||||||
cachedTenantLockout = {
|
// Direct return without caching
|
||||||
key: cacheKey,
|
|
||||||
result: successResult,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
return successResult;
|
return successResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
|
|||||||
|
|
||||||
export interface UpdateContractRequest {
|
export interface UpdateContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
status: ContractStatus;
|
status: ContractStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecuteContractRequest {
|
export interface ExecuteContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
externalSignatureId: string;
|
externalSignatureId: string;
|
||||||
signedDocumentUrl: string;
|
signedDocumentUrl: string;
|
||||||
}
|
}
|
||||||
@@ -838,6 +841,7 @@ export interface Invite {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy?: string | null;
|
updatedBy?: string | null;
|
||||||
@@ -852,6 +856,7 @@ export interface InviteRequest {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
message: string;
|
message: string;
|
||||||
platform: NotificationPlatform;
|
platform: NotificationPlatform;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = rawVal ?? '';
|
formatted = rawVal ?? '';
|
||||||
}
|
}
|
||||||
|
if (typeof formatted === 'string') {
|
||||||
|
// Strip out line breaks and carriage returns that distort Excel row heights and layout
|
||||||
|
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
}
|
||||||
rowObj[col.header] = formatted;
|
rowObj[col.header] = formatted;
|
||||||
});
|
});
|
||||||
return rowObj;
|
return rowObj;
|
||||||
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = String(rawVal ?? '');
|
formatted = String(rawVal ?? '');
|
||||||
}
|
}
|
||||||
|
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
return Math.max(max, formatted.length);
|
return Math.max(max, formatted.length);
|
||||||
}, headerLen);
|
}, headerLen);
|
||||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
|
||||||
});
|
});
|
||||||
worksheet['!cols'] = colWidths;
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
? import.meta.env
|
? import.meta.env
|
||||||
: {}) as Record<string, string | undefined>;
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export const DEFAULT_HASHER_SALT =
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||||
|
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||||
|
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
@@ -33,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
* Validates if candidate is already a HashID generated by this hasher.
|
* Validates if candidate is already a HashID generated by this hasher.
|
||||||
*/
|
*/
|
||||||
export function isHashId(candidate: string | null | undefined): boolean {
|
export function isHashId(candidate: string | null | undefined): boolean {
|
||||||
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
if (
|
||||||
|
!candidate ||
|
||||||
|
typeof candidate !== 'string' ||
|
||||||
|
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||||
|
/^\d+$/.test(candidate)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const decodedNumbers = hasher.decode(candidate);
|
const decodedNumbers = hasher.decode(candidate);
|
||||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
||||||
|
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const decodedHex = hasher.decodeHex(candidate);
|
const decodedHex = hasher.decodeHex(candidate);
|
||||||
if (decodedHex && decodedHex.length > 0) return true;
|
if (decodedHex && decodedHex.length > 0) {
|
||||||
|
if (hasher.encodeHex(decodedHex) === candidate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -198,23 +215,7 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
|||||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||||
*/
|
*/
|
||||||
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||||
if (
|
return isHashId(candidate);
|
||||||
!candidate ||
|
|
||||||
typeof candidate !== 'string' ||
|
|
||||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
|
||||||
/^\d+$/.test(candidate)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const decoded = hasher.decode(candidate);
|
|
||||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return hasher.encode(decoded as number[]) === candidate;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,6 +247,11 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
|
|||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
return Number(decoded[0]);
|
return Number(decoded[0]);
|
||||||
}
|
}
|
||||||
|
const decodedHex = hasher.decodeHex(str);
|
||||||
|
if (decodedHex) {
|
||||||
|
const num = Number(BigInt('0x' + decodedHex));
|
||||||
|
if (!isNaN(num)) return Math.trunc(num);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
@@ -270,6 +276,11 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
|||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
return Number(decoded[0]);
|
return Number(decoded[0]);
|
||||||
}
|
}
|
||||||
|
const decodedHex = hasher.decodeHex(str);
|
||||||
|
if (decodedHex) {
|
||||||
|
const num = Number(BigInt('0x' + decodedHex));
|
||||||
|
if (!isNaN(num)) return Math.trunc(num);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
const fallback = Number(str);
|
const fallback = Number(str);
|
||||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OIDC_AUDIENCE?: string;
|
readonly VITE_OIDC_AUDIENCE?: string;
|
||||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_HASHER_SALT?: string;
|
||||||
|
readonly VITE_HASHER_MIN_LENGTH?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
|
|||||||
builder.Property(f => f.IndicatorId).IsRequired();
|
builder.Property(f => f.IndicatorId).IsRequired();
|
||||||
builder.Property(f => f.ActivityId).IsRequired();
|
builder.Property(f => f.ActivityId).IsRequired();
|
||||||
builder.Property(f => f.CreatedBy).IsRequired();
|
builder.Property(f => f.CreatedBy).IsRequired();
|
||||||
builder.Property(f => f.UpdatedBy).IsRequired();
|
|
||||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||||
|
builder.Property(f => f.UpdatedBy).IsRequired(false);
|
||||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||||
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
|
||||||
builder.Property(f => f.DocumentUrl).IsRequired();
|
builder.Property(f => f.DocumentUrl).IsRequired();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NotificationPlatform : int
|
public enum NotificationPlatform : int
|
||||||
|
|||||||
+2519
File diff suppressed because it is too large
Load Diff
+37
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UpdatedByIsOptionalOnEvidence : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "Evidences",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "Evidences",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -604,7 +604,7 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid>("UpdatedBy")
|
b.Property<Guid?>("UpdatedBy")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<int>("Version")
|
b.Property<int>("Version")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
# AI Studio automatically injects this at runtime from user secrets.
|
|
||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
|
||||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# APP_URL: The URL where this applet is hosted.
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
|
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||||
|
VITE_HASHER_MIN_LENGTH="11"
|
||||||
|
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
|
||||||
|
|||||||
@@ -29,17 +29,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
|
|||||||
# 5. OIDC Scope
|
# 5. OIDC Scope
|
||||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
# 6. Hasher Settings
|
# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions)
|
||||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||||
|
|
||||||
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
||||||
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
|
# Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files.
|
||||||
cat <<EOF > "$TARGET_DIR/env-config.js"
|
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||||
// Generated dynamically at container startup from deployment environment variables
|
|
||||||
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
|
|
||||||
window.__RUNTIME_CONFIG__ = {
|
window.__RUNTIME_CONFIG__ = {
|
||||||
API_BASE_URL: "${API_BASE_URL}",
|
API_BASE_URL: "${API_BASE_URL}",
|
||||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||||
|
|||||||
@@ -10,6 +10,6 @@
|
|||||||
"BaseUrl": ""
|
"BaseUrl": ""
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
"MinHashLength": ""
|
"MinHashLength": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
// Runtime configuration for development or container environments
|
||||||
|
// Note: Secret values (like hashing salt or client secrets) are not exposed here.
|
||||||
|
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||||
|
HASHER_MIN_LENGTH: "11",
|
||||||
|
VITE_HASHER_MIN_LENGTH: "11",
|
||||||
|
HasherSettings: {
|
||||||
|
MinHashLength: "11"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { api, extractApiErrors } from '../../services/api';
|
import { createPortal } from 'react-dom';
|
||||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
uploadEvidenceFile,
|
||||||
|
extractApiErrors,
|
||||||
|
toIntegerId,
|
||||||
|
getProjectIndicators,
|
||||||
|
recordIndicatorActual,
|
||||||
|
} from '../../services/api';
|
||||||
|
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
|
||||||
|
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
@@ -10,14 +19,15 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Link as LinkIcon
|
Target,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface EvidenceUploadModalProps {
|
interface EvidenceUploadModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
project: Project;
|
project: Project;
|
||||||
milestone?: Milestone;
|
milestone?: Milestone | null;
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
onEvidenceUploaded: () => void;
|
onEvidenceUploaded: () => void;
|
||||||
}
|
}
|
||||||
@@ -32,14 +42,45 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [documentUrl, setDocumentUrl] = useState('');
|
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
|
||||||
const [evidenceType, setEvidenceType] = useState<EvidenceType>(1); // Invoice / Document
|
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
|
||||||
|
const [actualAmount, setActualAmount] = useState<string>('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
const projectId = project?.id || 0;
|
||||||
|
|
||||||
|
// Requirement #4: Fetch linked indicators for project & activity
|
||||||
|
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
|
||||||
|
queryKey: ['evidence-project-indicators', String(projectId)],
|
||||||
|
queryFn: () => getProjectIndicators(projectId),
|
||||||
|
enabled: isOpen && Boolean(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prioritize indicators directly linked to this activity, then project indicators
|
||||||
|
const availableIndicators = React.useMemo(() => {
|
||||||
|
if (!projectIndicators || projectIndicators.length === 0) return [];
|
||||||
|
const directMatches = projectIndicators.filter(
|
||||||
|
ind => ind.activityId && String(ind.activityId) === String(activity.id)
|
||||||
|
);
|
||||||
|
if (directMatches.length > 0) return directMatches;
|
||||||
|
return projectIndicators;
|
||||||
|
}, [projectIndicators, activity.id]);
|
||||||
|
|
||||||
|
// Auto-select first indicator if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableIndicators.length > 0 && !selectedIndicatorId) {
|
||||||
|
const firstId = String(availableIndicators[0].id);
|
||||||
|
setSelectedIndicatorId(firstId);
|
||||||
|
if (availableIndicators[0].actualAmount !== undefined) {
|
||||||
|
setActualAmount(String(availableIndicators[0].actualAmount || ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [availableIndicators, selectedIndicatorId]);
|
||||||
|
|
||||||
|
if (!isOpen || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
@@ -51,66 +92,94 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedIndicatorObj = availableIndicators.find(
|
||||||
|
i => String(i.id) === String(selectedIndicatorId)
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessages([]);
|
setErrorMessages([]);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
|
||||||
if (!title.trim() && !selectedFile && !documentUrl.trim()) {
|
if (!selectedFile) {
|
||||||
setErrorMessages(['Please provide an evidence title, file upload, or document URL.']);
|
setErrorMessages(['A supporting document file is required. Please choose a file to upload.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numProjectId = toIntegerId(project.id);
|
||||||
|
const numActivityId = toIntegerId(activity.id);
|
||||||
|
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
|
||||||
|
const numIndicatorId = toIntegerId(selectedIndicatorId);
|
||||||
|
|
||||||
|
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
|
||||||
|
if (!numIndicatorId || numIndicatorId <= 0) {
|
||||||
|
setErrorMessages([
|
||||||
|
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
|
||||||
|
setErrorMessages([
|
||||||
|
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numProjectId || !numActivityId) {
|
||||||
|
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build upload payload
|
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
|
||||||
|
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (selectedFile) {
|
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('projectId', String(numProjectId));
|
||||||
|
formData.append('activityId', String(numActivityId));
|
||||||
|
formData.append('milestoneId', String(numMilestoneId));
|
||||||
|
formData.append('indicatorId', String(numIndicatorId));
|
||||||
|
formData.append('type', String(Number(evidenceType) || 4));
|
||||||
|
formData.append('title', title.trim() || selectedFile.name);
|
||||||
|
if (description.trim()) {
|
||||||
|
formData.append('description', description.trim());
|
||||||
}
|
}
|
||||||
formData.append('title', title.trim() || selectedFile?.name || 'Evidence Deliverable');
|
|
||||||
formData.append('projectId', String(project.id || ''));
|
|
||||||
if (milestone?.id) formData.append('milestoneId', String(milestone.id));
|
|
||||||
if (activity?.id) formData.append('activityId', String(activity.id));
|
|
||||||
formData.append('type', String(evidenceType));
|
|
||||||
if (documentUrl.trim()) formData.append('documentUrl', documentUrl.trim());
|
|
||||||
if (description.trim()) formData.append('description', description.trim());
|
|
||||||
|
|
||||||
// Also support JSON payload fallback if server expects JSON body
|
// 2. Upload evidence file
|
||||||
|
await uploadEvidenceFile(formData);
|
||||||
|
|
||||||
|
// 3. Requirement #4: Record Actual against linked indicator
|
||||||
|
const parsedActual = Number(actualAmount);
|
||||||
|
if (!isNaN(parsedActual)) {
|
||||||
try {
|
try {
|
||||||
await api.evidence.createEvidenceFile(formData);
|
await recordIndicatorActual(numIndicatorId, parsedActual);
|
||||||
} catch (err: any) {
|
} catch (indErr) {
|
||||||
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
|
console.warn('[Record Indicator Actual Warning]:', indErr);
|
||||||
const jsonPayload = {
|
}
|
||||||
projectId: project.id,
|
|
||||||
milestoneId: milestone?.id,
|
|
||||||
activityId: activity.id,
|
|
||||||
title: title.trim() || selectedFile?.name || 'Evidence Deliverable',
|
|
||||||
type: evidenceType,
|
|
||||||
documentUrl: documentUrl.trim() || (selectedFile ? `s3://evidence/${selectedFile.name}` : undefined),
|
|
||||||
description: description.trim(),
|
|
||||||
};
|
|
||||||
await api.evidence.createEvidenceFile(jsonPayload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
|
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onEvidenceUploaded();
|
onEvidenceUploaded();
|
||||||
onClose();
|
onClose();
|
||||||
}, 1000);
|
}, 800);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Evidence Upload Error]:', err);
|
console.error('[Evidence Upload Error]:', err);
|
||||||
const errors = extractApiErrors(err);
|
const errors = extractApiErrors(err);
|
||||||
setErrorMessages(errors);
|
setErrorMessages(
|
||||||
|
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||||
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
|
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
|
||||||
{/* Ambient background glow */}
|
{/* Ambient background glow */}
|
||||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
@@ -124,6 +193,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
|
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -146,12 +216,12 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Alerts (Comma-delimited error list from ProblemDetails) */}
|
{/* Error Alerts */}
|
||||||
{errorMessages.length > 0 && (
|
{errorMessages.length > 0 && (
|
||||||
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
|
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
|
||||||
<div className="flex items-center gap-1.5 font-bold text-rose-300">
|
<div className="flex items-center gap-1.5 font-bold text-rose-300">
|
||||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
<span>Validation Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
|
<span>Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
|
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
|
||||||
{errorMessages.map((msg, idx) => (
|
{errorMessages.map((msg, idx) => (
|
||||||
@@ -173,10 +243,13 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||||
{/* File Dropzone */}
|
{/* File Dropzone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-medium mb-1.5">Evidence File (Document, Receipt, Report)</label>
|
<label className="block text-slate-300 font-medium mb-1.5">
|
||||||
|
Evidence File (Document, Invoice, Certificate, Photo) <span className="text-emerald-400">*</span>
|
||||||
|
</label>
|
||||||
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
|
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
required
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
@@ -193,23 +266,88 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-slate-300">Click to upload file</span>
|
<span className="font-medium text-slate-300">Click to choose document</span>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Max 25MB)</p>
|
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||||
|
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
|
||||||
|
<Target className="w-4 h-4" />
|
||||||
|
<span>Linked Indicator & Performance Actual (Mandatory)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-medium mb-1">
|
||||||
|
Select Linked Indicator <span className="text-emerald-400">*</span>
|
||||||
|
</label>
|
||||||
|
{isLoadingIndicators ? (
|
||||||
|
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
|
||||||
|
) : availableIndicators.length === 0 ? (
|
||||||
|
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
|
||||||
|
No indicators currently recorded for this project. Please contact programme management or select standard output.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={selectedIndicatorId}
|
||||||
|
onChange={e => setSelectedIndicatorId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose Indicator for this Deliverable --</option>
|
||||||
|
{availableIndicators.map(ind => (
|
||||||
|
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
|
||||||
|
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-medium mb-1">
|
||||||
|
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
placeholder={
|
||||||
|
selectedIndicatorObj
|
||||||
|
? `Target is ${selectedIndicatorObj.targetAmount}`
|
||||||
|
: 'e.g. 10 or 1500'
|
||||||
|
}
|
||||||
|
value={actualAmount}
|
||||||
|
onChange={e => setActualAmount(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
{selectedIndicatorObj && (
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1">
|
||||||
|
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Evidence Title & Type */}
|
{/* Evidence Title & Type */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
|
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Audit Signoff Dossier"
|
placeholder="e.g. Activity Verification Proof"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,42 +355,26 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
||||||
<select
|
<select
|
||||||
value={evidenceType}
|
value={evidenceType}
|
||||||
onChange={(e) => setEvidenceType(Number(e.target.value) as EvidenceType)}
|
onChange={e => setEvidenceType(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value={0}>Document</option>
|
{EVIDENCE_TYPE_OPTIONS.map(opt => (
|
||||||
<option value={1}>Invoice</option>
|
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
|
||||||
<option value={2}>Receipt</option>
|
{opt.label}
|
||||||
<option value={3}>Site Photo</option>
|
</option>
|
||||||
<option value={4}>Verification Report</option>
|
))}
|
||||||
<option value={5}>Other Deliverable</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External Secure URL (Optional) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-300 font-medium mb-1">Document Link / S3 URI (Optional)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<LinkIcon className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://... or s3://..."
|
|
||||||
value={documentUrl}
|
|
||||||
onChange={(e) => setDocumentUrl(e.target.value)}
|
|
||||||
className="w-full pl-8 pr-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description / Notes */}
|
{/* Description / Notes */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes</label>
|
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes (Optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Provide verification notes or audit references..."
|
placeholder="Provide verification notes or audit references..."
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
|
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Upload & Attach Evidence</span>
|
<span>Upload & Record Actual</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
AlertTriangle,
|
||||||
|
Scale,
|
||||||
|
FileCheck,
|
||||||
|
Info,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
Filter,
|
||||||
|
MapPin,
|
||||||
|
Target,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Project,
|
||||||
|
Milestone,
|
||||||
|
Activity,
|
||||||
|
ComplianceItem,
|
||||||
|
Risk,
|
||||||
|
Rule,
|
||||||
|
Beneficiary,
|
||||||
|
ComplianceStatus,
|
||||||
|
} from '../../services/api/models';
|
||||||
|
import {
|
||||||
|
getProjectComplianceItems,
|
||||||
|
getProjectBeneficiaries,
|
||||||
|
getProjectRisks,
|
||||||
|
getRules,
|
||||||
|
} from '../../services/api';
|
||||||
|
import { formatDate } from '../../lib/formatters';
|
||||||
|
|
||||||
|
interface GovernanceInfoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
project: Project;
|
||||||
|
milestone?: Milestone | null;
|
||||||
|
activity?: Activity | null;
|
||||||
|
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
project,
|
||||||
|
milestone,
|
||||||
|
activity,
|
||||||
|
initialTab = 'compliance',
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
|
||||||
|
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
|
||||||
|
|
||||||
|
const projectId = project?.id || 0;
|
||||||
|
|
||||||
|
// 1. Fetch compliance items for project
|
||||||
|
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
|
||||||
|
queryKey: ['governance-compliance', String(projectId)],
|
||||||
|
queryFn: () => getProjectComplianceItems(projectId),
|
||||||
|
enabled: isOpen && Boolean(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fetch beneficiaries for project (Requirement: explore compliance items by beneficiary)
|
||||||
|
const { data: projectBeneficiaries = [], isLoading: isLoadingBeneficiaries } = useQuery<Beneficiary[]>({
|
||||||
|
queryKey: ['governance-beneficiaries', String(projectId)],
|
||||||
|
queryFn: () => getProjectBeneficiaries(projectId),
|
||||||
|
enabled: isOpen && Boolean(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fetch risks for project
|
||||||
|
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
|
||||||
|
queryKey: ['governance-risks', String(projectId)],
|
||||||
|
queryFn: () => getProjectRisks(projectId),
|
||||||
|
enabled: isOpen && Boolean(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Fetch associated governance rules
|
||||||
|
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
|
||||||
|
queryKey: ['governance-rules'],
|
||||||
|
queryFn: () => getRules({ page: 1, pageSize: 100 }),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map of beneficiary by ID for fast lookup
|
||||||
|
const beneficiaryMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Beneficiary>();
|
||||||
|
for (const b of projectBeneficiaries) {
|
||||||
|
if (b && b.id !== undefined && b.id !== null) {
|
||||||
|
map.set(String(b.id), b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projectBeneficiaries]);
|
||||||
|
|
||||||
|
// Filter compliance items by selected beneficiary
|
||||||
|
const filteredComplianceItems = useMemo(() => {
|
||||||
|
if (selectedBeneficiaryFilter === 'ALL') {
|
||||||
|
return complianceItems;
|
||||||
|
}
|
||||||
|
if (selectedBeneficiaryFilter === 'PROJECT_WIDE') {
|
||||||
|
return complianceItems.filter(
|
||||||
|
item => !item.beneficiaryId || Number(item.beneficiaryId) === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return complianceItems.filter(
|
||||||
|
item => String(item.beneficiaryId) === selectedBeneficiaryFilter
|
||||||
|
);
|
||||||
|
}, [complianceItems, selectedBeneficiaryFilter]);
|
||||||
|
|
||||||
|
if (!isOpen || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const renderComplianceStatus = (status?: number) => {
|
||||||
|
const s = Number(status);
|
||||||
|
if (s === ComplianceStatus.Met || s === ComplianceStatus.Exceeded) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>{s === ComplianceStatus.Exceeded ? 'Exceeded' : 'Compliant'}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||||
|
<span>Action Required (Not Met)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||||
|
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||||
|
<ShieldAlert className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>Governance & Compliance Guidance</span>
|
||||||
|
</span>
|
||||||
|
{activity ? (
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
|
||||||
|
) : milestone ? (
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||||
|
Project Compliance Items, Beneficiaries & Associated Rules
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('compliance')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'compliance'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileCheck className="w-4 h-4" />
|
||||||
|
<span>Compliance Items ({complianceItems.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('beneficiaries')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'beneficiaries'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>Beneficiaries ({projectBeneficiaries.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('risks')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'risks'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span>Risk Register ({projectRisks.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('rules')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'rules'
|
||||||
|
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
<span>Associated Rules ({governanceRules.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content Panels */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||||
|
{/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */}
|
||||||
|
{activeTab === 'compliance' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Beneficiary Filter Bar */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-white/[0.08] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-300">
|
||||||
|
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<span className="font-semibold">Explore by Beneficiary:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedBeneficiaryFilter}
|
||||||
|
onChange={e => setSelectedBeneficiaryFilter(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="ALL">All Compliance Items ({complianceItems.length})</option>
|
||||||
|
<option value="PROJECT_WIDE">
|
||||||
|
Project-Wide General Items (
|
||||||
|
{
|
||||||
|
complianceItems.filter(
|
||||||
|
i => !i.beneficiaryId || Number(i.beneficiaryId) === 0
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</option>
|
||||||
|
{projectBeneficiaries.map(b => {
|
||||||
|
const count = complianceItems.filter(
|
||||||
|
i => String(i.beneficiaryId) === String(b.id)
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<option key={b.id} value={String(b.id)}>
|
||||||
|
Beneficiary: {b.name || `Beneficiary #${b.id}`} ({count} items)
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingCompliance ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
|
||||||
|
) : filteredComplianceItems.length === 0 ? (
|
||||||
|
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||||
|
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
|
||||||
|
<p>No compliance items found matching this beneficiary filter.</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Switch to "All Compliance Items" to inspect project-wide statutory deliverables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredComplianceItems.map((item, idx) => {
|
||||||
|
const linkedBeneficiary = item.beneficiaryId
|
||||||
|
? beneficiaryMap.get(String(item.beneficiaryId))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id || idx}
|
||||||
|
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-sm text-slate-100">
|
||||||
|
{item.title || `Compliance Item #${idx + 1}`}
|
||||||
|
</span>
|
||||||
|
{renderComplianceStatus(item.status)}
|
||||||
|
{linkedBeneficiary ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
|
||||||
|
<Users className="w-3 h-3 text-cyan-400" />
|
||||||
|
<span>Beneficiary: {linkedBeneficiary.name}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-slate-900 border border-white/10 text-slate-400">
|
||||||
|
<span>Project-Wide General</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<span>Created: {formatDate(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.requirements && (
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */}
|
||||||
|
{activeTab === 'beneficiaries' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoadingBeneficiaries ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-400">Loading project beneficiaries...</div>
|
||||||
|
) : projectBeneficiaries.length === 0 ? (
|
||||||
|
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||||
|
<Users className="w-8 h-8 text-slate-500 mx-auto" />
|
||||||
|
<p>No individual beneficiary cohorts registered for this project.</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Beneficiaries are captured during project execution and onboarding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projectBeneficiaries.map(ben => {
|
||||||
|
const linkedItems = complianceItems.filter(
|
||||||
|
ci => String(ci.beneficiaryId) === String(ben.id)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ben.id}
|
||||||
|
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">{ben.name || 'Unnamed Beneficiary'}</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono">
|
||||||
|
Category: {ben.category || 'Direct Participant'}
|
||||||
|
{ben.location && ` • Location: ${ben.location}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[10px] text-slate-500 block uppercase font-bold">Target / Reached</span>
|
||||||
|
<span className="text-emerald-400 font-mono font-semibold">
|
||||||
|
{ben.reachedCount ?? 0} / {ben.targetCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBeneficiaryFilter(String(ben.id));
|
||||||
|
setActiveTab('compliance');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-cyan-300 text-[11px] font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Explore Compliance ({linkedItems.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: RISK REGISTER */}
|
||||||
|
{activeTab === 'risks' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoadingRisks ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
||||||
|
) : projectRisks.length === 0 ? (
|
||||||
|
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||||
|
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
|
||||||
|
<p>No high-severity risks recorded for this project.</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Maintain milestone delivery cadence to preserve low-risk standing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projectRisks.map((risk, idx) => (
|
||||||
|
<div
|
||||||
|
key={risk.id || idx}
|
||||||
|
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<h4 className="font-semibold text-sm text-slate-100">
|
||||||
|
{risk.title || `Risk Item #${idx + 1}`}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-mono">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
|
Likelihood: {risk.likelihood ?? 'Medium'}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||||
|
Impact: {risk.impact ?? 'Moderate'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{risk.description && (
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
|
||||||
|
)}
|
||||||
|
{risk.mitigation && (
|
||||||
|
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
|
||||||
|
<strong>Mitigation Strategy:</strong> {risk.mitigation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
|
||||||
|
{activeTab === 'rules' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoadingRules ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
|
||||||
|
) : governanceRules.length === 0 ? (
|
||||||
|
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
|
||||||
|
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
|
||||||
|
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
All disbursements gate against verified milestone deliverables and evidence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
governanceRules.map((rule, idx) => (
|
||||||
|
<div
|
||||||
|
key={rule.id || idx}
|
||||||
|
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className="font-semibold text-sm text-slate-100">
|
||||||
|
{rule.name || `Rule #${idx + 1}`}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
|
||||||
|
v{rule.version || 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rule.description && (
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">
|
||||||
|
PFM Governance Architecture • Beneficiary & Compliance Tracking
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,32 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import { Copy, Check } from 'lucide-react';
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Mail,
|
|
||||||
MessageSquare,
|
|
||||||
Code2,
|
|
||||||
Eye,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { NotificationPlatform } from '../../types';
|
import { NotificationPlatform } from '../../types';
|
||||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||||
|
|
||||||
export interface MessageViewerProps {
|
export interface MessageViewerProps {
|
||||||
content: string;
|
content?: string;
|
||||||
|
message?: string;
|
||||||
platform?: NotificationPlatform | number | string;
|
platform?: NotificationPlatform | number | string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showFormatHeader?: boolean;
|
showFormatHeader?: boolean;
|
||||||
maxHeightClass?: string;
|
maxHeightClass?: string;
|
||||||
|
variant?: string;
|
||||||
|
createdDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
|
message,
|
||||||
platform,
|
platform,
|
||||||
subject,
|
subject,
|
||||||
className = '',
|
className = '',
|
||||||
showFormatHeader = true,
|
maxHeightClass = 'max-h-[65vh]',
|
||||||
maxHeightClass = 'max-h-[60vh]',
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
|
||||||
|
|
||||||
// Determine whether this message represents an Email (HTML) or In-App (Markup)
|
// Determine whether this message represents an Email (HTML) or In-App (Markdown)
|
||||||
const isEmail = useMemo(() => {
|
const isEmail = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
platform === NotificationPlatform.Email ||
|
platform === NotificationPlatform.Email ||
|
||||||
@@ -41,148 +36,81 @@ export const MessageViewer: React.FC<MessageViewerProps> = ({
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
|
||||||
if (
|
if (
|
||||||
platform !== NotificationPlatform.InApp &&
|
platform !== NotificationPlatform.InApp &&
|
||||||
platform !== 5 &&
|
platform !== 5 &&
|
||||||
platform !== '5' &&
|
platform !== '5' &&
|
||||||
String(platform).toLowerCase() !== 'inapp' &&
|
String(platform).toLowerCase() !== 'inapp' &&
|
||||||
isHtmlContent(content)
|
isHtmlContent(rawContent)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [platform, content]);
|
}, [platform, rawContent]);
|
||||||
|
|
||||||
const isInApp = useMemo(() => {
|
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
|
||||||
if (
|
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
|
||||||
platform === NotificationPlatform.InApp ||
|
|
||||||
platform === 5 ||
|
|
||||||
platform === '5' ||
|
|
||||||
String(platform).toLowerCase() === 'inapp'
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return !isEmail;
|
|
||||||
}, [platform, isEmail]);
|
|
||||||
|
|
||||||
// Convert the message to its respective format:
|
|
||||||
// - Email: HTML rendered
|
|
||||||
// - In-App: Markup (Markdown) parsed to HTML
|
|
||||||
const renderedHtml = useMemo(() => {
|
const renderedHtml = useMemo(() => {
|
||||||
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
|
if (!rawContent || !rawContent.trim()) {
|
||||||
|
return '<p class="text-slate-400 italic">No message content</p>';
|
||||||
|
}
|
||||||
|
|
||||||
// Format 1: Email => HTML
|
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
if (isHtmlContent(content)) {
|
if (isHtmlContent(rawContent)) {
|
||||||
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||||
}
|
}
|
||||||
return parseMarkdownToHtml(content);
|
return parseMarkdownToHtml(rawContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format 2: In-App => Markup (Markdown)
|
// In-App: parsed from Markdown to styled HTML
|
||||||
return parseMarkdownToHtml(content);
|
return parseMarkdownToHtml(rawContent);
|
||||||
}, [content, isEmail]);
|
}, [rawContent, isEmail]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
const textToCopy = stripMarkdown(rawContent) || rawContent;
|
||||||
navigator.clipboard.writeText(textToCopy);
|
navigator.clipboard.writeText(textToCopy);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||||
{/* Format Header & Controls Bar */}
|
{/* Top action toolbar - clean without technical format metadata */}
|
||||||
{showFormatHeader && (
|
<div className="px-5 py-3 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||||
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
<span className="text-xs font-semibold text-slate-200 truncate">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{subject || 'Message Content'}
|
||||||
{isEmail ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
|
|
||||||
<Mail className="w-3.5 h-3.5 text-cyan-400" />
|
|
||||||
<span>Email (HTML)</span>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
|
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>In-App (Markup)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-[11px] text-slate-400 hidden sm:inline">
|
{/* Copy Clean Text */}
|
||||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{/* View Mode Toggle: Formatted vs Raw */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowRaw(!showRaw)}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
|
||||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
|
||||||
>
|
|
||||||
{showRaw ? (
|
|
||||||
<>
|
|
||||||
<Eye className="w-3 h-3 text-emerald-400" />
|
|
||||||
<span>Formatted</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Code2 className="w-3 h-3" />
|
|
||||||
<span>Raw</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Copy Button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium transition cursor-pointer border border-white/10"
|
||||||
title="Copy message content"
|
title="Copy message content"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3 text-emerald-400" />
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span className="text-emerald-300">Copied</span>
|
<span className="text-emerald-300">Copied</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>Copy</span>
|
<span>Copy</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message Body Content - with explicit max height, max width, word wrapping, and automatic scrollbars */}
|
{/* Rendered Body - Prose & Markdown styling */}
|
||||||
<div className={`w-full max-w-full p-4 sm:p-6 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}>
|
<div
|
||||||
{showRaw ? (
|
className={`w-full max-w-full p-5 sm:p-7 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}
|
||||||
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words [word-break:break-word] bg-[#080d18] p-4 rounded-xl border border-white/5 max-w-full overflow-x-auto [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
>
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
|
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Info Stamp */}
|
|
||||||
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-[10px] text-slate-400">
|
|
||||||
{stripMarkdown(content).length} characters
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Heading2,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Minus,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
Eraser,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { htmlToMarkdown } from '../../lib/markdownUtils';
|
||||||
|
|
||||||
|
interface PartnerMessageComposerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
organisationName?: string;
|
||||||
|
initialSubject?: string;
|
||||||
|
onSend: (subject: string, markdownMessage: string) => Promise<void>;
|
||||||
|
isSending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PartnerMessageComposerModal: React.FC<PartnerMessageComposerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
recipientEmail,
|
||||||
|
recipientName,
|
||||||
|
organisationName,
|
||||||
|
initialSubject = '',
|
||||||
|
onSend,
|
||||||
|
isSending = false,
|
||||||
|
}) => {
|
||||||
|
const [subject, setSubject] = useState(initialSubject);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSubject(initialSubject);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, [initialSubject, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const applyFormat = (command: string, value: string | undefined = undefined) => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFormat = () => {
|
||||||
|
applyFormat('removeFormat');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
if (!subject.trim()) {
|
||||||
|
setErrorMessage('Please enter a message subject.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = editorRef.current ? editorRef.current.innerHTML : '';
|
||||||
|
const textContent = editorRef.current ? editorRef.current.innerText.trim() : '';
|
||||||
|
|
||||||
|
if (!textContent && !htmlContent.trim()) {
|
||||||
|
setErrorMessage('Please write your message in the composer space.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requirement #1: In-App messages are encoded as Markdown
|
||||||
|
// Requirement #8: User is none the wiser of raw format
|
||||||
|
const markdown = htmlToMarkdown(htmlContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSend(subject.trim(), markdown);
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMessage(err?.message || 'Failed to dispatch message. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
|
||||||
|
<div className="relative w-full max-w-2xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-7 space-y-5 my-auto text-slate-100">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.08] pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400">
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">
|
||||||
|
{initialSubject.startsWith('Re:') ? 'Reply Message' : 'Compose Message'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Direct transmission to your programme funding administration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Recipient Field */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
To (Recipient Support Email)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
value={recipientEmail || 'support@ifri.khongisa.co.za'}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs font-mono text-emerald-400 select-none"
|
||||||
|
/>
|
||||||
|
{(organisationName || recipientName) && (
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1">
|
||||||
|
Organisation: <span className="text-slate-200">{organisationName || recipientName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject Field */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
placeholder="e.g. Milestone 2 Deliverable Verification Enquiry"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WYSIWYG Message Composition Space (Requirement #2 & #8) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Message Composition Space
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950 overflow-hidden shadow-inner focus-within:border-emerald-500/50 transition">
|
||||||
|
{/* WYSIWYG Toolbar */}
|
||||||
|
<div className="px-3 py-2 border-b border-white/[0.08] bg-slate-900/80 flex flex-wrap items-center gap-1 text-slate-300">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('bold')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('italic')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('underline')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<Underline className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Heading"
|
||||||
|
>
|
||||||
|
<Heading2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="w-px h-4 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('insertUnorderedList')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('insertOrderedList')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Numbered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Quote Block"
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('insertHorizontalRule')}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
|
||||||
|
title="Horizontal Divider"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={handleClearFormat}
|
||||||
|
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer ml-auto"
|
||||||
|
title="Clear Formatting"
|
||||||
|
>
|
||||||
|
<Eraser className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rich ContentEditable Area */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
className="w-full min-h-[160px] max-h-[300px] p-4 text-xs sm:text-sm text-slate-100 leading-relaxed overflow-y-auto focus:outline-none [scrollbar-width:thin] [scrollbar-color:#334155_transparent] prose prose-invert max-w-none [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5"
|
||||||
|
data-placeholder="Write your message here..."
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSending}
|
||||||
|
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
getUserNotifications,
|
getUserNotifications,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
createNotification,
|
||||||
|
updateNotification,
|
||||||
|
archiveNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
|
resolveUserIdByEmail,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import {
|
import {
|
||||||
Notification,
|
Notification,
|
||||||
@@ -113,7 +116,6 @@ export function useInAppNotifications() {
|
|||||||
return list || [];
|
return list || [];
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const inAppNotifications = useMemo(() => {
|
const inAppNotifications = useMemo(() => {
|
||||||
@@ -124,7 +126,10 @@ export function useInAppNotifications() {
|
|||||||
|
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
n =>
|
||||||
|
!trashedIds.includes(String(n.id)) &&
|
||||||
|
Number(n.status) !== NotificationStatus.Archived &&
|
||||||
|
Number(n.status) !== NotificationStatus.Draft
|
||||||
);
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
@@ -151,7 +156,9 @@ export function useInAppNotifications() {
|
|||||||
}, [inAppNotifications, starredIds, trashedIds]);
|
}, [inAppNotifications, starredIds, trashedIds]);
|
||||||
|
|
||||||
const trashedNotifications = useMemo(() => {
|
const trashedNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
return inAppNotifications.filter(
|
||||||
|
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
|
||||||
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
@@ -191,7 +198,8 @@ export function useInAppNotifications() {
|
|||||||
});
|
});
|
||||||
}, [starredStorageKey]);
|
}, [starredStorageKey]);
|
||||||
|
|
||||||
const deleteMessage = useCallback((id: string | number) => {
|
// Delete message (NotificationStatus == Archived & move to trash)
|
||||||
|
const deleteMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||||
@@ -201,9 +209,22 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [trashStorageKey]);
|
|
||||||
|
|
||||||
const restoreMessage = useCallback((id: string | number) => {
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await archiveNotification(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[deleteMessage] Error archiving notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
|
// Restore from trash / unarchive
|
||||||
|
const restoreMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.filter(x => x !== strId);
|
const next = prev.filter(x => x !== strId);
|
||||||
@@ -213,7 +234,19 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [trashStorageKey]);
|
|
||||||
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
|
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markNotificationAsRead(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[restoreMessage] Error restoring notification on server:', err);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||||
|
}
|
||||||
|
}, [trashStorageKey, userId, queryClient]);
|
||||||
|
|
||||||
const permanentDelete = useCallback((id: string | number) => {
|
const permanentDelete = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
@@ -270,8 +303,9 @@ export function useInAppNotifications() {
|
|||||||
|
|
||||||
const sendMessageMutation = useMutation({
|
const sendMessageMutation = useMutation({
|
||||||
mutationFn: async (payload: {
|
mutationFn: async (payload: {
|
||||||
recipientId: string;
|
recipientId?: string;
|
||||||
recipientEmail: string;
|
recipientEmail?: string;
|
||||||
|
recipient?: string;
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -281,11 +315,23 @@ export function useInAppNotifications() {
|
|||||||
}) => {
|
}) => {
|
||||||
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
||||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
|
const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za';
|
||||||
|
let id = payload.recipientId && payload.recipientId !== '00000000-0000-0000-0000-000000000000'
|
||||||
|
? payload.recipientId
|
||||||
|
: (!payload.recipient?.includes('@') && payload.recipient !== '00000000-0000-0000-0000-000000000000' ? payload.recipient : '');
|
||||||
|
|
||||||
|
if (!id && email) {
|
||||||
|
id = (await resolveUserIdByEmail(email, orgId)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
id = '00000000-0000-0000-0000-000000000000';
|
||||||
|
}
|
||||||
|
const formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`;
|
||||||
|
|
||||||
const req: CreateNotificationRequest = {
|
const req: CreateNotificationRequest = {
|
||||||
organisationId: orgId,
|
organisationId: orgId,
|
||||||
recipient: payload.recipientId,
|
recipient: id,
|
||||||
platform: targetPlatform,
|
platform: targetPlatform,
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
destination: formattedDestination,
|
destination: formattedDestination,
|
||||||
@@ -298,7 +344,7 @@ export function useInAppNotifications() {
|
|||||||
const sentItem: Notification = {
|
const sentItem: Notification = {
|
||||||
id: `sent_${Date.now()}`,
|
id: `sent_${Date.now()}`,
|
||||||
organisationId: orgId,
|
organisationId: orgId,
|
||||||
recipient: payload.recipientId,
|
recipient: payload.recipientId || payload.recipient || "",
|
||||||
platform: targetPlatform,
|
platform: targetPlatform,
|
||||||
status: NotificationStatus.Sent,
|
status: NotificationStatus.Sent,
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
|||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
|
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -462,14 +463,36 @@ export const getOrganisationTypeLabel = (type: OrganisationType | number): strin
|
|||||||
return opt ? opt.label : 'Organisation';
|
return opt ? opt.label : 'Organisation';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
|
export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => {
|
||||||
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
|
if (sector === undefined || sector === null || sector === '') return '';
|
||||||
return opt ? opt.label : 'Industry Sector';
|
const num = Number(sector);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === num);
|
||||||
|
if (opt) return opt.label;
|
||||||
|
}
|
||||||
|
const str = String(sector).trim().toLowerCase();
|
||||||
|
for (const opt of INDUSTRY_SECTOR_OPTIONS) {
|
||||||
|
if (opt.label.toLowerCase() === str) return opt.label;
|
||||||
|
const enumKey = (IndustrySector as any)[opt.value];
|
||||||
|
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
|
||||||
|
}
|
||||||
|
return String(sector).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectTypeLabel = (type: ProjectType | number): string => {
|
export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => {
|
||||||
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
|
if (type === undefined || type === null || type === '') return '';
|
||||||
return opt ? opt.label : 'Project Type';
|
const num = Number(type);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === num);
|
||||||
|
if (opt) return opt.label;
|
||||||
|
}
|
||||||
|
const str = String(type).trim().toLowerCase();
|
||||||
|
for (const opt of PROJECT_TYPE_OPTIONS) {
|
||||||
|
if (opt.label.toLowerCase() === str) return opt.label;
|
||||||
|
const enumKey = (ProjectType as any)[opt.value];
|
||||||
|
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
|
||||||
|
}
|
||||||
|
return String(type).replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
|
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
|
||||||
@@ -514,6 +537,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case NotificationStatus.Archived:
|
||||||
|
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||||
case NotificationStatus.Draft:
|
case NotificationStatus.Draft:
|
||||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||||
case NotificationStatus.Delivered:
|
case NotificationStatus.Delivered:
|
||||||
|
|||||||
@@ -149,3 +149,79 @@ export function parseMarkdownToHtml(markdown: string): string {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts formatted HTML from WYSIWYG composition into clean, semantic Markdown.
|
||||||
|
* Preserves headings, bold, italic, blockquotes, lists, dividers, and links.
|
||||||
|
*/
|
||||||
|
export function htmlToMarkdown(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
let md = html
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
||||||
|
|
||||||
|
// Line breaks & paragraph breaks
|
||||||
|
md = md.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
md = md.replace(/<\/p>/gi, '\n\n');
|
||||||
|
md = md.replace(/<p[^>]*>/gi, '');
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gis, '# $1\n\n');
|
||||||
|
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gis, '## $1\n\n');
|
||||||
|
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gis, '### $1\n\n');
|
||||||
|
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gis, '#### $1\n\n');
|
||||||
|
|
||||||
|
// Bold / Italic / Underline
|
||||||
|
md = md.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gis, '**$2**');
|
||||||
|
md = md.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gis, '*$2*');
|
||||||
|
md = md.replace(/<u[^>]*>(.*?)<\/u>/gis, '$1');
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_match, p1) => {
|
||||||
|
return (
|
||||||
|
p1
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((l: string) => `> ${l.trim()}`)
|
||||||
|
.join('\n') + '\n\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unordered Lists
|
||||||
|
md = md.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (_match, p1) => {
|
||||||
|
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, '- $1\n') + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordered Lists
|
||||||
|
md = md.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (_match, p1) => {
|
||||||
|
let index = 1;
|
||||||
|
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, () => `${index++}. $1\n`) + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizontal Rules
|
||||||
|
md = md.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||||
|
|
||||||
|
// Links
|
||||||
|
md = md.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gis, '[$2]($1)');
|
||||||
|
|
||||||
|
// Divs
|
||||||
|
md = md.replace(/<\/div>/gi, '\n');
|
||||||
|
md = md.replace(/<div[^>]*>/gi, '');
|
||||||
|
|
||||||
|
// Strip any remaining HTML tags
|
||||||
|
md = md.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
// Unescape standard HTML entities
|
||||||
|
md = md
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
// Collapse 3+ newlines into 2
|
||||||
|
return md.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
// Remove caching from all pages: zero stale time and zero cache retention
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 0,
|
||||||
refetchOnReconnect: true,
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always',
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||||
|
const RETAINED_CACHE_OPTIONS = {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export const evidenceApi = {
|
|||||||
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
|
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
|
||||||
|
|
||||||
getProjectEvidence: (projectId: string | number) =>
|
getProjectEvidence: (projectId: string | number) =>
|
||||||
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/${hashParam(projectId)}`),
|
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/projects/${hashParam(projectId)}`),
|
||||||
|
|
||||||
createMilestoneRequest: (payload: Models.CreateMilestoneRequest) =>
|
createMilestoneRequest: (payload: Models.CreateMilestoneRequest) =>
|
||||||
apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload),
|
apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload),
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ import {
|
|||||||
UpdateRuleRequest,
|
UpdateRuleRequest,
|
||||||
Pagination,
|
Pagination,
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationPlatform,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
Role,
|
Role,
|
||||||
Claim,
|
Claim,
|
||||||
@@ -810,6 +811,7 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
activityId: toIntegerId(payload.activityId),
|
||||||
projectId: toIntegerId(payload.projectId),
|
projectId: toIntegerId(payload.projectId),
|
||||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||||
category: payload.category || 'Operations',
|
category: payload.category || 'Operations',
|
||||||
@@ -830,6 +832,46 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const submitActivityForApproval = async (
|
||||||
|
activity: Activity,
|
||||||
|
projectId: number | string,
|
||||||
|
evidences?: Evidence[]
|
||||||
|
): Promise<void> => {
|
||||||
|
if (evidences && evidences.length > 0) {
|
||||||
|
for (const ev of evidences) {
|
||||||
|
if (Number(ev.status) === ApprovalStatus.Pending) {
|
||||||
|
try {
|
||||||
|
await updateEvidence({
|
||||||
|
evidenceId: ev.id,
|
||||||
|
version: ev.version || 1,
|
||||||
|
evidenceType: (ev as any).evidenceType ?? ev.type,
|
||||||
|
status: ApprovalStatus.Review,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not update evidence status', ev.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActivity({
|
||||||
|
activityId: activity.id,
|
||||||
|
projectId,
|
||||||
|
milestoneId: activity.milestoneId,
|
||||||
|
category: activity.category || 'Operations',
|
||||||
|
amount: activity.amount || 0,
|
||||||
|
title: activity.title || 'Activity',
|
||||||
|
description: activity.description || '',
|
||||||
|
performedAt: new Date().toISOString(),
|
||||||
|
status: ApprovalStatus.Review,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not update activity fulfillment record', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
|
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
try {
|
try {
|
||||||
@@ -1377,12 +1419,12 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
|
|||||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||||
try {
|
try {
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
||||||
try {
|
try {
|
||||||
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
|
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
||||||
return rawRes.data || [];
|
return rawRes.data || [];
|
||||||
} catch (fallbackErr: any) {
|
} catch (fallbackErr: any) {
|
||||||
if (fallbackErr?.response?.status === 404) {
|
if (fallbackErr?.response?.status === 404) {
|
||||||
@@ -1857,7 +1899,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
|||||||
subject: payload.subject || null,
|
subject: payload.subject || null,
|
||||||
destination: payload.destination || 'In-App User Inbox',
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
status: payload.status !== undefined ? payload.status : undefined,
|
status: payload.status !== undefined
|
||||||
|
? payload.status
|
||||||
|
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1879,6 +1923,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
|
||||||
|
await updateNotification({
|
||||||
|
notificationId: toIntegerId(notificationId),
|
||||||
|
status: NotificationStatus.Archived,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 9. Governance: Tenants APIs
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getTenants = async (): Promise<Tenant[]> => {
|
export const getTenants = async (): Promise<Tenant[]> => {
|
||||||
@@ -1955,6 +2006,91 @@ export const getTenantUsers = async (tenantId: string | number): Promise<TenantU
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
/**
|
||||||
|
* Resolves a recipient user GUID by email address across TenantUsers and Directory.
|
||||||
|
* Automatically caches any found user emails and IDs in local memory.
|
||||||
|
*/
|
||||||
|
export const resolveUserIdByEmail = async (
|
||||||
|
email: string,
|
||||||
|
hintTenantOrOrgId?: number | string
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
// 1. Fast cache check
|
||||||
|
const cachedId = getUserIdByEmail(cleanEmail);
|
||||||
|
if (cachedId && cachedId !== '00000000-0000-0000-0000-000000000000') {
|
||||||
|
return cachedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchInTenantUsers = (tenantUsers: TenantUser[]): string | undefined => {
|
||||||
|
if (!Array.isArray(tenantUsers)) return undefined;
|
||||||
|
let match: string | undefined = undefined;
|
||||||
|
for (const tu of tenantUsers) {
|
||||||
|
const uId = tu.userId || tu.user?.id;
|
||||||
|
const uEmail = tu.user?.email || (tu as any)?.email;
|
||||||
|
if (uId && uEmail) {
|
||||||
|
registerUserEmail(uId, uEmail);
|
||||||
|
if (String(uEmail).trim().toLowerCase() === cleanEmail) {
|
||||||
|
match = String(uId).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Query tenant users with hintTenantOrOrgId
|
||||||
|
if (hintTenantOrOrgId) {
|
||||||
|
try {
|
||||||
|
const users = await getTenantUsers(hintTenantOrOrgId);
|
||||||
|
const match = matchInTenantUsers(users);
|
||||||
|
if (match) return match;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Query all tenants from getTenants and their users
|
||||||
|
try {
|
||||||
|
const tenants = await getTenants();
|
||||||
|
for (const t of tenants) {
|
||||||
|
if (hintTenantOrOrgId && String(t.id) === String(hintTenantOrOrgId)) continue;
|
||||||
|
try {
|
||||||
|
const users = await getTenantUsers(t.id);
|
||||||
|
const match = matchInTenantUsers(users);
|
||||||
|
if (match) return match;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 4. Try standard fallback tenant IDs
|
||||||
|
for (const tid of [1, 2, 3]) {
|
||||||
|
if (hintTenantOrOrgId && String(tid) === String(hintTenantOrOrgId)) continue;
|
||||||
|
try {
|
||||||
|
const users = await getTenantUsers(tid);
|
||||||
|
const match = matchInTenantUsers(users);
|
||||||
|
if (match) return match;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Query user directory fallback
|
||||||
|
try {
|
||||||
|
const dirUsers = await apiClient.post<User[]>('/v1/api/users/1/200', {});
|
||||||
|
if (Array.isArray(dirUsers.data)) {
|
||||||
|
for (const u of dirUsers.data) {
|
||||||
|
if (u && u.id && u.email) {
|
||||||
|
registerUserEmail(u.id, u.email);
|
||||||
|
if (u.email.trim().toLowerCase() === cleanEmail) {
|
||||||
|
return String(u.id).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// 10. Projects: Beneficiaries & Outcomes APIs
|
// 10. Projects: Beneficiaries & Outcomes APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
|
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; version=1',
|
'Content-Type': 'application/json; version=1',
|
||||||
Accept: 'application/json; version=1',
|
Accept: 'application/json; version=1',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
@@ -58,33 +60,18 @@ apiClient.interceptors.request.use(
|
|||||||
config.url?.includes('amazonaws.com') ||
|
config.url?.includes('amazonaws.com') ||
|
||||||
config.url?.includes('garage');
|
config.url?.includes('garage');
|
||||||
|
|
||||||
|
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||||
|
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||||
const headers = config.headers || {};
|
|
||||||
const hasNoCacheHeader =
|
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
if (config.params instanceof URLSearchParams) {
|
||||||
config.params.set('_t', String(Date.now()));
|
config.params.set('_t', String(Date.now()));
|
||||||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
} else {
|
} else {
|
||||||
config.params = config.params || {};
|
config.params = config.params || {};
|
||||||
config.params._t = Date.now();
|
config.params._t = Date.now();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
config.params._v = getMutationEpoch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token && !isLifecycleRequest) {
|
if (token && !isLifecycleRequest) {
|
||||||
if (isActivationPending()) {
|
if (isActivationPending()) {
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
? import.meta.env
|
? import.meta.env
|
||||||
: {}) as Record<string, string | undefined>;
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export const DEFAULT_HASHER_SALT =
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||||
|
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
||||||
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
||||||
*/
|
*/
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
@@ -21,9 +25,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||||
HASHER_SETTINGS.salt = salt.trim();
|
HASHER_SETTINGS.salt = salt.trim();
|
||||||
updated = true;
|
updated = true;
|
||||||
|
} else if (!HASHER_SETTINGS.salt) {
|
||||||
|
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
|
||||||
|
updated = true;
|
||||||
}
|
}
|
||||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||||
const parsed = Number(minLength);
|
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
|
||||||
|
const parsed = Number(cleanMin);
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
HASHER_SETTINGS.minHashLength = parsed;
|
HASHER_SETTINGS.minHashLength = parsed;
|
||||||
updated = true;
|
updated = true;
|
||||||
@@ -34,8 +42,9 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAlreadyHashId(candidate: string): boolean {
|
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||||
if (
|
if (
|
||||||
|
!candidate ||
|
||||||
typeof candidate !== 'string' ||
|
typeof candidate !== 'string' ||
|
||||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||||
/^\d+$/.test(candidate)
|
/^\d+$/.test(candidate)
|
||||||
@@ -47,21 +56,29 @@ export function isAlreadyHashId(candidate: string): boolean {
|
|||||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return hasher.encode(decoded as number[]) === candidate;
|
return hasher.encode(decoded as any) === candidate;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
|
||||||
if (id === undefined || id === null || id === '') {
|
if (id === undefined || id === null || id === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof id === 'bigint') {
|
||||||
|
if (id < 0n) return '';
|
||||||
|
return hasher.encode(id);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof id === 'number') {
|
if (typeof id === 'number') {
|
||||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||||
|
if (id <= Number.MAX_SAFE_INTEGER) {
|
||||||
return hasher.encode(id);
|
return hasher.encode(id);
|
||||||
}
|
}
|
||||||
|
return hasher.encode(BigInt(Math.trunc(id)));
|
||||||
|
}
|
||||||
|
|
||||||
const str = String(id).trim();
|
const str = String(id).trim();
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
@@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
|
|||||||
|
|
||||||
if (/^\d+$/.test(str)) {
|
if (/^\d+$/.test(str)) {
|
||||||
try {
|
try {
|
||||||
const num = parseInt(str, 10);
|
// JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss
|
||||||
if (!isNaN(num) && num >= 0) {
|
return hasher.encode(BigInt(str));
|
||||||
return hasher.encode(num);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through
|
// Fall through
|
||||||
}
|
}
|
||||||
@@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
export function decodeHashIdSync(hashId: string | number | bigint | null | undefined): number | string {
|
||||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||||
if (typeof hashId === 'number') return hashId;
|
if (typeof hashId === 'number') return hashId;
|
||||||
|
if (typeof hashId === 'bigint') return hashId.toString();
|
||||||
|
|
||||||
const str = String(hashId).trim();
|
const str = String(hashId).trim();
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
|
|
||||||
if (/^-?\d+$/.test(str)) {
|
if (/^-?\d+$/.test(str)) {
|
||||||
const parsed = parseInt(str, 10);
|
try {
|
||||||
return isNaN(parsed) ? str : parsed;
|
const b = BigInt(str);
|
||||||
|
if (b <= BigInt(Number.MAX_SAFE_INTEGER) && b >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||||
|
return Number(b);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
} catch {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = hasher.decode(str);
|
const decoded = hasher.decode(str);
|
||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
return Number(decoded[0]);
|
const val = decoded[0];
|
||||||
|
if (typeof val === 'bigint') {
|
||||||
|
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||||
|
return Number(val);
|
||||||
|
}
|
||||||
|
return val.toString();
|
||||||
|
}
|
||||||
|
return Number(val);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If decoding fails, return raw string
|
// If decoding fails, return raw string
|
||||||
@@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
export function decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
|
||||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||||
if (typeof hashId === 'number') {
|
if (typeof hashId === 'number') {
|
||||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||||
}
|
}
|
||||||
|
if (typeof hashId === 'bigint') {
|
||||||
|
return Number(hashId);
|
||||||
|
}
|
||||||
const str = String(hashId).trim();
|
const str = String(hashId).trim();
|
||||||
if (/^-?\d+$/.test(str)) {
|
if (/^-?\d+$/.test(str)) {
|
||||||
const parsed = parseInt(str, 10);
|
const parsed = parseInt(str, 10);
|
||||||
@@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const toolsService = {
|
export const toolsService = {
|
||||||
encodeIdSync(id: number | string | null | undefined): string {
|
encodeIdSync(id: number | string | bigint | null | undefined): string {
|
||||||
return encodeHashIdSync(id);
|
return encodeHashIdSync(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
decodeIdSync(hashId: string | number | bigint | null | undefined): number | string {
|
||||||
return decodeHashIdSync(hashId);
|
return decodeHashIdSync(hashId);
|
||||||
},
|
},
|
||||||
|
|
||||||
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
|
||||||
return decodeToNumberSync(hashId);
|
return decodeToNumberSync(hashId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
async encodeId(id: number | string | bigint | null | undefined, _context = 'general'): Promise<string> {
|
||||||
return encodeHashIdSync(id);
|
return encodeHashIdSync(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,35 +182,35 @@ export const toolsService = {
|
|||||||
return decodeHashIdSync(hashId);
|
return decodeHashIdSync(hashId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
async resolveProjectId(projectId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(projectId);
|
return encodeHashIdSync(projectId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
async resolveOrganisationId(organisationId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(organisationId);
|
return encodeHashIdSync(organisationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
async resolveProgrammeId(programmeId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(programmeId);
|
return encodeHashIdSync(programmeId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
async resolvePortfolioId(portfolioId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(portfolioId);
|
return encodeHashIdSync(portfolioId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
async resolveAwardId(awardId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(awardId);
|
return encodeHashIdSync(awardId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveContractId(contractId: number | string): Promise<string> {
|
async resolveContractId(contractId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(contractId);
|
return encodeHashIdSync(contractId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveActivityId(activityId: number | string): Promise<string> {
|
async resolveActivityId(activityId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(activityId);
|
return encodeHashIdSync(activityId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
|
async resolveMilestoneId(milestoneId: number | string | bigint): Promise<string> {
|
||||||
return encodeHashIdSync(milestoneId);
|
return encodeHashIdSync(milestoneId);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -200,7 +233,7 @@ export const toolsService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useHashId(id?: number | string | null, context = 'general') {
|
export function useHashId(id?: number | string | bigint | null, context = 'general') {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
||||||
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
? import.meta.env
|
? import.meta.env
|
||||||
: {}) as Record<string, string | undefined>;
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export const DEFAULT_HASHER_SALT =
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||||
|
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||||
|
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
@@ -16,9 +20,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||||
HASHER_SETTINGS.salt = salt.trim();
|
HASHER_SETTINGS.salt = salt.trim();
|
||||||
updated = true;
|
updated = true;
|
||||||
|
} else if (!HASHER_SETTINGS.salt) {
|
||||||
|
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
|
||||||
|
updated = true;
|
||||||
}
|
}
|
||||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||||
const parsed = Number(minLength);
|
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
|
||||||
|
const parsed = Number(cleanMin);
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
HASHER_SETTINGS.minHashLength = parsed;
|
HASHER_SETTINGS.minHashLength = parsed;
|
||||||
updated = true;
|
updated = true;
|
||||||
@@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean {
|
|||||||
if (
|
if (
|
||||||
Array.isArray(decodedNumbers) &&
|
Array.isArray(decodedNumbers) &&
|
||||||
decodedNumbers.length > 0 &&
|
decodedNumbers.length > 0 &&
|
||||||
hasher.encode(decodedNumbers as number[]) === candidate
|
hasher.encode(decodedNumbers as any) === candidate
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -149,23 +157,24 @@ export function toSafeInt64String(val: string | number | bigint | null | undefin
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
||||||
|
* Uses BigInt for full int64 range, interoperable with C# Hashids.net EncodeLong.
|
||||||
*/
|
*/
|
||||||
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
||||||
if (id === undefined || id === null || id === '') return '';
|
if (id === undefined || id === null || id === '') return '';
|
||||||
|
|
||||||
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
if (typeof id === 'bigint') {
|
||||||
|
if (id < 0n) return '';
|
||||||
|
return hasher.encode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(id).trim();
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
|
|
||||||
if (isHashId(str)) return str;
|
if (isHashId(str)) return str;
|
||||||
|
|
||||||
if (/^\d+$/.test(str)) {
|
if (/^\d+$/.test(str)) {
|
||||||
try {
|
try {
|
||||||
const num = Number(str);
|
return hasher.encode(BigInt(str));
|
||||||
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
|
||||||
return hasher.encode(num);
|
|
||||||
}
|
|
||||||
const bigHex = BigInt(str).toString(16);
|
|
||||||
return hasher.encodeHex(bigHex);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through
|
// Fall through
|
||||||
}
|
}
|
||||||
@@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
|||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
return decoded[0].toString();
|
return decoded[0].toString();
|
||||||
}
|
}
|
||||||
const decodedHex = hasher.decodeHex(str);
|
|
||||||
if (decodedHex) {
|
|
||||||
try {
|
|
||||||
return BigInt('0x' + decodedHex).toString();
|
|
||||||
} catch {
|
|
||||||
return decodedHex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Return raw
|
// Return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_OIDC_AUTHORITY?: string;
|
||||||
|
readonly VITE_OIDC_AUDIENCE?: string;
|
||||||
|
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_HASHER_SALT?: string;
|
||||||
|
readonly VITE_HASHER_MIN_LENGTH?: string;
|
||||||
|
readonly [key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2022",
|
"ES2022",
|
||||||
"DOM",
|
"DOM",
|
||||||
|
|||||||
Reference in New Issue
Block a user