Compare commits
193
Commits
finishline
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac317cbe3 | ||
|
|
0bcc3484dc | ||
|
|
fddf400ddd | ||
|
|
a1b8410bc2 | ||
|
|
cb0da32ce4 | ||
|
|
70e286670e | ||
|
|
c6075a5c39 | ||
|
|
4e1211a1e1 | ||
|
|
c8e54815ef | ||
|
|
31d775c917 | ||
|
|
78d147b002 | ||
|
|
07018c3ce4 | ||
|
|
db08bbf162 | ||
|
|
ac357285c3 | ||
|
|
a4927f9742 | ||
|
|
fac88f3471 | ||
|
|
aeb7542cfe | ||
|
|
9724bbac24 | ||
|
|
deb21dbb9a | ||
|
|
98fe1e3e3d | ||
|
|
d9b65010cf | ||
|
|
bc441c1255 | ||
|
|
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ 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:-11}}}}}"
|
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==}}}}"
|
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
|
|||||||
@@ -186,7 +186,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 +193,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -124,7 +124,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.Active,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,6 +777,7 @@ 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.Active,
|
||||||
});
|
});
|
||||||
@@ -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,9 +1839,61 @@ 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>
|
</div>
|
||||||
|
|
||||||
|
{/* Missing ID Number Alert */}
|
||||||
|
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||||
|
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-amber-200">
|
||||||
|
Missing Executive ID Number
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||||
|
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Executive User ID Number Input */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Executive ID Requirement"
|
||||||
|
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedExecutiveUser?.idNumber && (
|
||||||
|
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...orgForm.register('idNumber')}
|
||||||
|
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||||
|
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter 13-digit ID or Passport Number"
|
||||||
|
/>
|
||||||
|
{orgForm.formState.errors.idNumber && (
|
||||||
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Inline Collapsible Subform to Create Executive User */}
|
{/* Inline Collapsible Subform to Create Executive User */}
|
||||||
{isQuickAddUserOpen && !editingOrg && (
|
{isQuickAddUserOpen && !editingOrg && (
|
||||||
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
||||||
@@ -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>
|
||||||
<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}`}>
|
<div className="flex items-center gap-2">
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
<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>
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
|
</span>
|
||||||
|
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||||
|
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Approve Programme</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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,
|
||||||
@@ -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(
|
||||||
@@ -826,8 +832,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}
|
||||||
@@ -919,10 +927,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 +981,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}
|
||||||
@@ -1059,10 +1069,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 +1143,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}
|
||||||
@@ -1549,7 +1561,7 @@ 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;
|
return;
|
||||||
}
|
}
|
||||||
updateAwardMutation.mutate({
|
updateAwardMutation.mutate({
|
||||||
@@ -1630,7 +1642,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
|
||||||
@@ -1783,7 +1795,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 {
|
||||||
|
|||||||
@@ -152,7 +152,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 {
|
||||||
|
|||||||
@@ -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);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredInstructionDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackInstruction: Instrument = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredInstruction(fallbackInstruction);
|
|
||||||
return fallbackInstruction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createInstruction = createInstrument;
|
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) {
|
||||||
@@ -2246,75 +2116,20 @@ 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) {
|
return res.data;
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
|
||||||
if (localRule) return localRule;
|
|
||||||
throw new Error('Rule not found');
|
|
||||||
}
|
}
|
||||||
throw new Error('Rule not found');
|
throw new Error('Rule not found');
|
||||||
};
|
};
|
||||||
@@ -2332,38 +2147,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[] = [];
|
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||||
let apiSucceeded = false;
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||||
try {
|
page: pagination.page || 1,
|
||||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
pageSize: pagination.pageSize || 100,
|
||||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
});
|
||||||
page: pagination.page || 1,
|
let result = Array.isArray(res.data) ? res.data : [];
|
||||||
pageSize: pagination.pageSize || 100,
|
|
||||||
});
|
|
||||||
if (Array.isArray(res.data)) {
|
|
||||||
apiRules = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[getRules] Error fetching from backend API:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredRuleDuplicates(apiRules);
|
|
||||||
let result = apiRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
||||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local storage only if API call failed
|
|
||||||
const localRules = getStoredRules();
|
|
||||||
let combined = localRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||||
}
|
}
|
||||||
return combined;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||||
@@ -2375,70 +2168,21 @@ 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);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackRule: Rule = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(fallbackRule);
|
|
||||||
return fallbackRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
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,
|
name: payload.name,
|
||||||
name: payload.name,
|
description: payload.description,
|
||||||
description: payload.description,
|
status: Number(payload.status),
|
||||||
status: Number(payload.status),
|
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,34 +83,16 @@ 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 || {};
|
if (config.params instanceof URLSearchParams) {
|
||||||
const hasNoCacheHeader =
|
config.params.set('_t', String(Date.now()));
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
// Explicit freshness requested: attach fresh timestamp
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
config.params.set('_t', String(Date.now()));
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
config.params._t = Date.now();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Standard GET: attach current mutation epoch
|
config.params = config.params || {};
|
||||||
if (config.params instanceof URLSearchParams) {
|
config.params._t = Date.now();
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
config.params._v = getMutationEpoch();
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { api, extractApiErrors } from '../../services/api';
|
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
|
||||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
||||||
|
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
@@ -17,7 +18,7 @@ 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,8 +33,7 @@ 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 [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||||
@@ -56,53 +56,47 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
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);
|
||||||
|
|
||||||
|
if (!numProjectId || !numActivityId) {
|
||||||
|
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build upload payload
|
// Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
|
||||||
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', '0');
|
||||||
|
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
|
await uploadEvidenceFile(formData);
|
||||||
try {
|
|
||||||
await api.evidence.createEvidenceFile(formData);
|
|
||||||
} catch (err: any) {
|
|
||||||
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
|
|
||||||
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!');
|
||||||
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 try again.']);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -124,6 +118,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 +141,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 +168,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,8 +191,8 @@ 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>
|
||||||
@@ -207,7 +205,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
|
|||||||
<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"
|
||||||
@@ -217,37 +215,21 @@ 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..."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
|
|||||||
return list || [];
|
return list || [];
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const inAppNotifications = useMemo(() => {
|
const inAppNotifications = useMemo(() => {
|
||||||
|
|||||||
@@ -462,14 +462,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 => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -810,6 +810,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 +831,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 +1418,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) {
|
||||||
|
|||||||
@@ -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,31 +60,16 @@ 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 || {};
|
if (config.params instanceof URLSearchParams) {
|
||||||
const hasNoCacheHeader =
|
config.params.set('_t', String(Date.now()));
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
config.params.set('_t', String(Date.now()));
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
config.params._t = Date.now();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (config.params instanceof URLSearchParams) {
|
config.params = config.params || {};
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
config.params._t = Date.now();
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
config.params._v = getMutationEpoch();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +56,28 @@ 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 '';
|
||||||
return hasher.encode(id);
|
if (id <= Number.MAX_SAFE_INTEGER) {
|
||||||
|
return hasher.encode(id);
|
||||||
|
}
|
||||||
|
return hasher.encode(BigInt(Math.trunc(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const str = String(id).trim();
|
const str = String(id).trim();
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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