Compare commits
191
Commits
25e251aa79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e286670e | ||
|
|
c6075a5c39 | ||
|
|
4e1211a1e1 | ||
|
|
c8e54815ef | ||
|
|
31d775c917 | ||
|
|
78d147b002 | ||
|
|
07018c3ce4 | ||
|
|
db08bbf162 | ||
|
|
ac357285c3 | ||
|
|
a4927f9742 | ||
|
|
fac88f3471 | ||
|
|
aeb7542cfe | ||
|
|
9724bbac24 | ||
|
|
deb21dbb9a | ||
|
|
98fe1e3e3d | ||
|
|
48edfa1cbb | ||
|
|
d9b65010cf | ||
|
|
3e1d8bc1cc | ||
|
|
bc441c1255 | ||
|
|
17a4149a73 | ||
|
|
6e786ca796 | ||
|
|
90631d22ae | ||
|
|
010f2d5eaa | ||
|
|
b8f5ebf69a | ||
|
|
957e75a853 | ||
|
|
8a06a86eb3 | ||
|
|
f8f826979d | ||
|
|
c4a0469bae | ||
|
|
a3f461478c | ||
|
|
cc62696017 | ||
|
|
d4598e0c6c | ||
|
|
43656e848a | ||
|
|
3a3245b643 | ||
|
|
fdaf12a183 | ||
|
|
6cdd03a02f | ||
|
|
1d1060d031 | ||
|
|
b0c89362d0 | ||
|
|
dee9ccf0b1 | ||
|
|
d9b3e80f7c | ||
|
|
8688c752d6 | ||
|
|
937a5595d8 | ||
|
|
58ce606410 | ||
|
|
a98bfa4308 | ||
|
|
6e56a4c223 | ||
|
|
e38edd24a0 | ||
|
|
1ac0018926 | ||
|
|
5ad59f6569 | ||
|
|
71a0abf0b2 | ||
|
|
627885e36b | ||
|
|
447ffb366e | ||
|
|
a9bb319eb1 | ||
|
|
bbba869cdd | ||
|
|
5ae2622454 | ||
|
|
bd0f03344d | ||
|
|
1af860fdc1 | ||
|
|
c04ae9a933 | ||
|
|
0e853811a9 | ||
|
|
3a3d5daefc | ||
|
|
fe9b9052e9 | ||
|
|
d25545ddad | ||
|
|
7effc32498 | ||
|
|
8909bcf5c4 | ||
|
|
411e603f51 | ||
|
|
138ef84973 | ||
|
|
312721bf51 | ||
|
|
dd59294911 | ||
|
|
28a3a17a15 | ||
|
|
4c2b0cd7d5 | ||
|
|
275fd109e6 | ||
|
|
248cc2fa2d | ||
|
|
761d14beb9 | ||
|
|
931e1a8698 | ||
|
|
aff58caeb0 | ||
|
|
1c5080bf04 | ||
|
|
1fb157aa0a | ||
|
|
0adfae1616 | ||
|
|
4856b6e6f4 | ||
|
|
0ba47269aa | ||
|
|
5e25fd7a11 | ||
|
|
f8fff04065 | ||
|
|
aca5882ad3 | ||
|
|
6b027193d1 | ||
|
|
81702a4bf8 | ||
|
|
5eeb1cbfc7 | ||
|
|
fb00986f59 | ||
|
|
0338737385 | ||
|
|
82c0936b4c | ||
|
|
5d2a886f6f | ||
|
|
dc4047ca6d | ||
|
|
46fe14d9ef | ||
|
|
f17368d939 | ||
|
|
7aea3fa8de | ||
|
|
d43eb17aa3 | ||
|
|
01547d8c3f | ||
|
|
18dd9131cb | ||
|
|
f82a5cef76 | ||
|
|
614730caab | ||
|
|
9823cd7ea6 | ||
|
|
a7a68fc290 | ||
|
|
4dbc74e488 | ||
|
|
d658497545 | ||
|
|
b947959724 | ||
|
|
58126e7573 | ||
|
|
2fede443cc | ||
|
|
80f220fcae | ||
|
|
06e2c3ee6a | ||
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||
|
||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||
|
||||
var changedTotal = await context.Organisations
|
||||
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
|
||||
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
|
||||
.SetProperty(u => u.Type, request.Type)
|
||||
.SetProperty(u => u.UserId, request.OrganisationUser)
|
||||
.SetProperty(u => u.Status, request.Status), cancellationToken);
|
||||
|
||||
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
|
||||
|
||||
@@ -159,12 +159,7 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
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."));
|
||||
return Result.Fail(NotFoundError.New);
|
||||
|
||||
var budget = new Domain.Entities.Budget
|
||||
{
|
||||
|
||||
@@ -24,3 +24,4 @@ VITE_ENABLE_HTTPS="true"
|
||||
|
||||
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||
VITE_HASHER_MIN_LENGTH="11"
|
||||
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
|
||||
|
||||
@@ -30,16 +30,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
|
||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}}"
|
||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
||||
|
||||
if [ -d "$TARGET_DIR" ]; then
|
||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||
|
||||
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
||||
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
|
||||
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||
// 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__ = {
|
||||
API_BASE_URL: "${API_BASE_URL}",
|
||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||
@@ -53,6 +52,8 @@ window.__RUNTIME_CONFIG__ = {
|
||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||
HASHER_SALT: "${HASHER_SALT}",
|
||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||
ApiSettings: {
|
||||
BaseUrl: "${API_BASE_URL}"
|
||||
},
|
||||
@@ -63,6 +64,7 @@ window.__RUNTIME_CONFIG__ = {
|
||||
Scope: "${OIDC_SCOPE}"
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: "${HASHER_SALT}",
|
||||
MinHashLength: "${HASHER_MIN_LENGTH}"
|
||||
}
|
||||
};
|
||||
@@ -81,6 +83,7 @@ EOF
|
||||
"Scope": "${OIDC_SCOPE}"
|
||||
},
|
||||
"HasherSettings": {
|
||||
"Salt": "${HASHER_SALT}",
|
||||
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"Scope": ""
|
||||
},
|
||||
"HasherSettings": {
|
||||
"MinHashLength": ""
|
||||
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
"MinHashLength": "11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
// Development and fallback runtime configuration.
|
||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
|
||||
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
HASHER_MIN_LENGTH: "11",
|
||||
VITE_HASHER_MIN_LENGTH: "11",
|
||||
HasherSettings: {
|
||||
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
||||
MinHashLength: "11"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -186,7 +186,6 @@ export const AppLayout: React.FC = () => {
|
||||
useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||
@@ -194,7 +193,6 @@ export const AppLayout: React.FC = () => {
|
||||
queryKey: ['active-tenant-header', tenantId],
|
||||
queryFn: () => getTenant(tenantId!),
|
||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Ensure current logged-in user is registered in the email cache
|
||||
|
||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
||||
<span>Active / Executed</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
|
||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
title="Approved evidence is immutable. Status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||
|
||||
@@ -44,7 +44,7 @@ export const defaultApiSettings: ApiSettings = {
|
||||
};
|
||||
|
||||
export const defaultHasherSettings: HasherSettings = {
|
||||
Salt: '',
|
||||
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
@@ -300,7 +300,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: salt || '',
|
||||
Salt: salt || defaultHasherSettings.Salt,
|
||||
MinHashLength: minHashLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
|
||||
@@ -124,7 +124,6 @@ export function useInAppNotifications() {
|
||||
},
|
||||
// Live polling every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||
|
||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
// Remove caching from all pages: zero stale time and zero cache retention
|
||||
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||
const RETAINED_CACHE_OPTIONS = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||
|
||||
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Resolved & Enacted (Immutable)</span>
|
||||
<span>Resolved & Enacted</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
title="Approved and enacted change requests are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Enacted</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Edit2,
|
||||
Mail,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
ChevronRight,
|
||||
Search,
|
||||
@@ -24,9 +25,11 @@ import {
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -69,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||
import { RulesSection } from '../components/governance/RulesSection';
|
||||
@@ -110,6 +114,7 @@ const organisationSchema = z.object({
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||
idNumber: z.string().optional(),
|
||||
type: z.coerce.number().min(1).max(27),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -134,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
||||
|
||||
export const GovernancePage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { sub, user, claims } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const { sub, user, claims, roles, tenantId } = useAuth();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
|
||||
const currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -241,7 +246,6 @@ export const GovernancePage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||
@@ -263,6 +267,37 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
}, [usersDirectory]);
|
||||
|
||||
const modalExecutiveUsers = React.useMemo(() => {
|
||||
const list: User[] = [...executiveAndManagerUsers];
|
||||
const linkedUserId =
|
||||
editingOrg?.userId ||
|
||||
editingOrg?.UserId ||
|
||||
'';
|
||||
|
||||
if (linkedUserId) {
|
||||
const linkedStr = String(linkedUserId).toLowerCase();
|
||||
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (!alreadyIncluded) {
|
||||
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
|
||||
if (inDirectory) {
|
||||
list.unshift(inDirectory);
|
||||
} else {
|
||||
const email = getUserEmail(linkedUserId);
|
||||
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
|
||||
list.unshift({
|
||||
id: String(linkedUserId),
|
||||
email: hasRealEmail ? email : String(linkedUserId),
|
||||
role: 'Executive',
|
||||
status: ActivityStatus.Active,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
|
||||
|
||||
// Quick Add Executive User Subform state
|
||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||
@@ -560,11 +595,23 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: '',
|
||||
idNumber: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
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>({
|
||||
resolver: zodResolver(portfolioSchema),
|
||||
defaultValues: {
|
||||
@@ -580,7 +627,7 @@ export const GovernancePage: React.FC = () => {
|
||||
defaultValues: {
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
},
|
||||
});
|
||||
@@ -589,11 +636,20 @@ export const GovernancePage: React.FC = () => {
|
||||
const orgMutation = useMutation({
|
||||
mutationFn: (data: OrganisationFormValues) => {
|
||||
if (editingOrg) {
|
||||
if (!data.idNumber || !data.idNumber.trim()) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Government ID number is required to assign this executive user.',
|
||||
});
|
||||
throw new Error('Executive user ID number is required when updating an organisation.');
|
||||
}
|
||||
|
||||
return updateOrganisation({
|
||||
organisationId: editingOrg.id,
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber.trim(),
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||
@@ -603,11 +659,13 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber?.trim() || null,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||
setIsOrgModalOpen(false);
|
||||
@@ -639,6 +697,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||
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({
|
||||
mutationFn: (data: ProgrammeFormValues) => {
|
||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||
if (editingProgramme) {
|
||||
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||
}
|
||||
return updateProgramme({
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||
status: targetStatus,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -669,7 +756,9 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||
setIsProgrammeModalOpen(false);
|
||||
setEditingProgramme(null);
|
||||
@@ -688,6 +777,7 @@ export const GovernancePage: React.FC = () => {
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
organisationUser: defaultUser?.id || '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
});
|
||||
@@ -702,26 +792,51 @@ export const GovernancePage: React.FC = () => {
|
||||
const targetUserId =
|
||||
org.userId ||
|
||||
org.UserId ||
|
||||
org.organisationUser ||
|
||||
org.OrganisationUser ||
|
||||
org.createdBy ||
|
||||
'';
|
||||
|
||||
const matchedUser = targetUserId
|
||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
||||
: null;
|
||||
let selectedUserId = '';
|
||||
let matchedUser: User | undefined;
|
||||
if (targetUserId) {
|
||||
const targetStr = String(targetUserId).toLowerCase();
|
||||
matchedUser = usersDirectory.find(
|
||||
(u) =>
|
||||
(u.id && String(u.id).toLowerCase() === targetStr) ||
|
||||
(u.email && u.email.toLowerCase() === targetStr)
|
||||
);
|
||||
|
||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
||||
if (matchedUser) {
|
||||
selectedUserId = matchedUser.id;
|
||||
if (matchedUser.email) {
|
||||
registerUserEmail(matchedUser.id, matchedUser.email);
|
||||
}
|
||||
} else {
|
||||
selectedUserId = String(targetUserId);
|
||||
const email = getUserEmail(targetUserId);
|
||||
if (email && email !== '—' && email.includes('@')) {
|
||||
registerUserEmail(selectedUserId, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
|
||||
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
organisationUser: selectedUserId,
|
||||
idNumber: resolvedIdNumber,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
|
||||
if (selectedUserId && !resolvedIdNumber) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
|
||||
});
|
||||
}
|
||||
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
@@ -755,7 +870,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeForm.reset({
|
||||
name: '',
|
||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||
status: ApprovalStatus.Approved,
|
||||
status: ApprovalStatus.Pending,
|
||||
ownerEmail: effectiveUserEmail,
|
||||
});
|
||||
setIsProgrammeModalOpen(true);
|
||||
@@ -833,6 +948,7 @@ export const GovernancePage: React.FC = () => {
|
||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||
createdAt: (p) => p.createdAt,
|
||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||
createdBy: (p) => getUserEmail(p.createdBy),
|
||||
});
|
||||
|
||||
// Export Columns Definitions
|
||||
@@ -863,6 +979,7 @@ export const GovernancePage: React.FC = () => {
|
||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||
},
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||
];
|
||||
@@ -881,6 +998,7 @@ export const GovernancePage: React.FC = () => {
|
||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1150,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{org.email}
|
||||
</span>
|
||||
{org.userId && (
|
||||
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
|
||||
Exec: {getUserEmail(org.userId)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
@@ -1382,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
|
||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Owner Email
|
||||
</SortableTh>
|
||||
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||
Created By
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{loadingProgrammes ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading programmes...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedProgrammes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
No programmes found matching query.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1402,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
|
||||
sortedProgrammes.map((pr) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||
return (
|
||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5">
|
||||
@@ -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]">
|
||||
{getUserEmail(pr.ownedBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{getUserEmail(pr.createdBy)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||
{isApproved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Edit Programme"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
{canApproveRecord('governance', pr.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
title="Approve Programme"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1458,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
|
||||
<Scale className="w-3 h-3" />
|
||||
Rules
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditProgramme(pr)}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1611,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked Executive / Manager User *
|
||||
Responsible Executive User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
||||
title="Executive / Organisation User"
|
||||
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
@@ -1636,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser')}
|
||||
{...orgForm.register('organisationUser', {
|
||||
onChange: (e) => {
|
||||
const selectedId = e.target.value;
|
||||
if (!selectedId) {
|
||||
orgForm.setValue('idNumber', '');
|
||||
return;
|
||||
}
|
||||
const str = String(selectedId).toLowerCase();
|
||||
const matched =
|
||||
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
|
||||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
|
||||
|
||||
if (matched?.idNumber && matched.idNumber.trim()) {
|
||||
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
|
||||
orgForm.clearErrors('idNumber');
|
||||
} else {
|
||||
orgForm.setValue('idNumber', '', { shouldValidate: true });
|
||||
if (editingOrg) {
|
||||
orgForm.setError('idNumber', {
|
||||
type: 'manual',
|
||||
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
})}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select linked executive or manager account --</option>
|
||||
{executiveAndManagerUsers.map((u) => (
|
||||
{modalExecutiveUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} ({u.role || 'Executive'})
|
||||
</option>
|
||||
@@ -1654,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
|
||||
No Executive or Manager user found. Create one below to continue.
|
||||
</p>
|
||||
)}
|
||||
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing ID Number Alert */}
|
||||
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
|
||||
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-amber-200">
|
||||
Missing Executive ID Number
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/90 leading-relaxed">
|
||||
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Executive User ID Number Input */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Executive ID Number {editingOrg ? '*' : '(Optional)'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Executive ID Requirement"
|
||||
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{selectedExecutiveUser?.idNumber && (
|
||||
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
{...orgForm.register('idNumber')}
|
||||
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
|
||||
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
|
||||
}`}
|
||||
placeholder="Enter 13-digit ID or Passport Number"
|
||||
/>
|
||||
{orgForm.formState.errors.idNumber && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline Collapsible Subform to Create Executive User */}
|
||||
{isQuickAddUserOpen && !editingOrg && (
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
||||
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||
title="Approved programmes are immutable."
|
||||
title="Approved programmes cannot be modified."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
@@ -2460,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
|
||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
|
||||
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
|
||||
<span className="text-slate-300 font-mono text-[11px] truncate">
|
||||
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||
</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}`}>
|
||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||
</span>
|
||||
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={approveProgrammeMutation.isPending}
|
||||
onClick={async () => {
|
||||
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||
setIsProgrammeDetailModalOpen(false);
|
||||
setSelectedProgrammeForDetail(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<span>Approve Programme</span>
|
||||
</button>
|
||||
) : (
|
||||
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker: Record creator cannot approve
|
||||
</span>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Details Grid: Owner & Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
{/* Creation Date */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
@@ -2745,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maker */}
|
||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics: Total Rules and Total Instructions */}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { bumpMutationEpoch } from '../services/httpClient';
|
||||
import {
|
||||
Award,
|
||||
Budget,
|
||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateAwardMutation = useMutation({
|
||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Award Updated', 'Award details saved successfully.');
|
||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget line established', 'Budget line created against project');
|
||||
setIsBudgetModalOpen(false);
|
||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const updateBudgetMutation = useMutation({
|
||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||
setIsEditBudgetModalOpen(false);
|
||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
success(
|
||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success(
|
||||
@@ -826,8 +832,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchAwards();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||
await refetchAwards();
|
||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingAwards}
|
||||
@@ -919,10 +927,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved award is immutable. Details cannot be modified."
|
||||
title="Approved award details cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
@@ -973,8 +981,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchBudgets();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
await refetchBudgets();
|
||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingBudgets}
|
||||
@@ -1059,10 +1069,10 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||
title="Approved budget is immutable. Status cannot be modified."
|
||||
title="Approved budget status cannot be modified."
|
||||
>
|
||||
<Lock className="w-3 h-3 text-emerald-400" />
|
||||
<span>Approved (Immutable)</span>
|
||||
<span>Approved</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1133,8 +1143,10 @@ export const GrantsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refetchDisbursements();
|
||||
onClick={async () => {
|
||||
bumpMutationEpoch();
|
||||
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||
await refetchDisbursements();
|
||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||
}}
|
||||
disabled={isFetchingDisbursements}
|
||||
@@ -1549,7 +1561,7 @@ export const GrantsPage: React.FC = () => {
|
||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||
if (!editingAward) return;
|
||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
||||
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||
return;
|
||||
}
|
||||
updateAwardMutation.mutate({
|
||||
@@ -1630,7 +1642,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -1783,7 +1795,7 @@ export const GrantsPage: React.FC = () => {
|
||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
|
||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
@@ -152,7 +152,6 @@ export const SettingsPage: React.FC = () => {
|
||||
const { data: usersDirectory = [] } = useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
title="Approved site visits are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
||||
<span>Approved & Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
} = useQuery<TenantOrganisation[]>({
|
||||
queryKey: ['tenant-organisations', tenantId],
|
||||
queryFn: () => getTenantOrganisations(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Fetch tenant users directory to resolve linked user email per organisation
|
||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { canAdd } = useRbac();
|
||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
} = useQuery<Tenant[]>({
|
||||
queryKey: ['tenants-list'],
|
||||
queryFn: () => getTenants(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Forms
|
||||
|
||||
@@ -259,7 +259,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
idNumber: payload.idNumber || null,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: Number(payload.type),
|
||||
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 => {
|
||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
if (!raw) return;
|
||||
const local: Instrument[] = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
export const getStoredInstructions = (): Instrument[] => [];
|
||||
|
||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored instruction duplicates', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredInstructions = (): Instrument[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = getStoredInstructions();
|
||||
const idx = current.findIndex(
|
||||
(i) => String(i.id) === String(inst.id) ||
|
||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
||||
);
|
||||
if (idx >= 0) {
|
||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
||||
} else {
|
||||
current.unshift(inst);
|
||||
}
|
||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stored instruction', e);
|
||||
}
|
||||
};
|
||||
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||
|
||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||
let apiItems: Instrument[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||
if (Array.isArray(res.data)) {
|
||||
apiItems = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
} catch (err) {
|
||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredInstructionDuplicates(apiItems);
|
||||
return apiItems;
|
||||
}
|
||||
|
||||
// Fallback to local items only when API failed
|
||||
const localItems = getStoredInstructions().filter(
|
||||
(item) => String(item.programmeId) === String(programmeId)
|
||||
);
|
||||
return localItems;
|
||||
};
|
||||
|
||||
export const getInstructions = getInstruments;
|
||||
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
||||
}
|
||||
|
||||
const map = new Map<string, Instrument>();
|
||||
let hasApiData = false;
|
||||
|
||||
if (progList && progList.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
progList.map((pr) => getInstruments(pr.id))
|
||||
);
|
||||
results.forEach((res) => {
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
||||
hasApiData = true;
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasApiData || map.size > 0) {
|
||||
const allApiInstruments = Array.from(map.values());
|
||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
||||
return allApiInstruments;
|
||||
}
|
||||
|
||||
return getStoredInstructions();
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const getAllInstructions = getAllInstruments;
|
||||
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredInstructionDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackInstruction: Instrument = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredInstruction(fallbackInstruction);
|
||||
return fallbackInstruction;
|
||||
}
|
||||
|
||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
code: code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||
createdBy: payload.userId || 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const createInstruction = createInstrument;
|
||||
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
status: Number(payload.status),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (res.data && res.data.id) {
|
||||
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);
|
||||
}
|
||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Funding Instrument',
|
||||
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return updatedInstrument;
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updateInstruction = updateInstrument;
|
||||
@@ -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 => {
|
||||
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;
|
||||
export const getStoredRules = (): Rule[] => [];
|
||||
|
||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
||||
|
||||
const remaining = local.filter(
|
||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
||||
);
|
||||
|
||||
if (remaining.length !== local.length) {
|
||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to purge stored rule duplicates', e);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||
|
||||
// ==========================================
|
||||
// 15. Execution: Programme Rules APIs
|
||||
// ==========================================
|
||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||
try {
|
||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||
if (res.data) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch {
|
||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
||||
if (localRule) return localRule;
|
||||
throw new Error('Rule not found');
|
||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||
if (res.data) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error('Rule not found');
|
||||
};
|
||||
@@ -2332,38 +2147,16 @@ export const getRules = async (
|
||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||
programmeId?: string | number
|
||||
): Promise<Rule[]> => {
|
||||
let apiRules: Rule[] = [];
|
||||
let apiSucceeded = false;
|
||||
try {
|
||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||
page: pagination.page || 1,
|
||||
pageSize: pagination.pageSize || 100,
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
apiRules = res.data;
|
||||
apiSucceeded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[getRules] Error fetching from backend API:', err);
|
||||
}
|
||||
|
||||
if (apiSucceeded) {
|
||||
purgeStoredRuleDuplicates(apiRules);
|
||||
let result = apiRules;
|
||||
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;
|
||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||
page: pagination.page || 1,
|
||||
pageSize: pagination.pageSize || 100,
|
||||
});
|
||||
let result = Array.isArray(res.data) ? res.data : [];
|
||||
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> => {
|
||||
@@ -2375,70 +2168,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||
if (res.data && res.data.id) {
|
||||
purgeStoredRuleDuplicates([res.data]);
|
||||
return res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
||||
const newId = Date.now();
|
||||
const fallbackRule: Rule = {
|
||||
id: newId,
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(fallbackRule);
|
||||
return fallbackRule;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Date.now(),
|
||||
programmeId: toIntegerId(payload.programmeId),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: ActivityStatus.Active,
|
||||
version: 1,
|
||||
createdBy: validUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||
const validUserId = toValidUuid(payload.userId);
|
||||
try {
|
||||
await apiClient.put('/v1/api/execution/rules', {
|
||||
ruleId: toIntegerId(payload.ruleId),
|
||||
userId: validUserId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[updateRule] API update failed:', err);
|
||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
||||
const updatedRule: Rule = {
|
||||
id: payload.ruleId,
|
||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
createdBy: existing?.createdBy || validUserId,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedBy: validUserId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
saveStoredRule(updatedRule);
|
||||
}
|
||||
await apiClient.put('/v1/api/execution/rules', {
|
||||
ruleId: toIntegerId(payload.ruleId),
|
||||
userId: validUserId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
status: Number(payload.status),
|
||||
version: Number(payload.version || 1),
|
||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
config.url?.includes('garage');
|
||||
|
||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
||||
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||
const headers = config.headers || {};
|
||||
const hasNoCacheHeader =
|
||||
headers['Cache-Control'] === 'no-cache' ||
|
||||
headers['cache-control'] === 'no-cache' ||
|
||||
headers['Pragma'] === 'no-cache';
|
||||
|
||||
if (hasNoCacheHeader) {
|
||||
// Explicit freshness requested: attach fresh timestamp
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
} else {
|
||||
// Standard GET: attach current mutation epoch
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
}
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
if (!config.params._v && !config.params._t) {
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
let cachedTenantLockout: {
|
||||
key: string;
|
||||
result: TenantLockoutResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export function clearTenantLockoutCache(): void {
|
||||
cachedTenantLockout = null;
|
||||
// Caching removed to guarantee fresh tenant state on every verification
|
||||
}
|
||||
|
||||
export function isStatusActive(status: any): boolean {
|
||||
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
|
||||
candidateId = tenantClaim;
|
||||
}
|
||||
|
||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||
if (
|
||||
cachedTenantLockout.result.isActive &&
|
||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedTenantLockout.result;
|
||||
}
|
||||
}
|
||||
// Fresh check: query endpoint directly without cache
|
||||
|
||||
let tenantRes: Tenant | null = null;
|
||||
let lastError: any = null;
|
||||
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
|
||||
isActive: true,
|
||||
tenant: tenantObj,
|
||||
};
|
||||
cachedTenantLockout = {
|
||||
key: cacheKey,
|
||||
result: successResult,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Direct return without caching
|
||||
return successResult;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const DEFAULT_HASHER_SALT =
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
||||
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
||||
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
||||
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
@@ -33,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
* Validates if candidate is already a HashID generated by this hasher.
|
||||
*/
|
||||
export function isHashId(candidate: string | null | undefined): boolean {
|
||||
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decodedNumbers = hasher.decode(candidate);
|
||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
||||
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(candidate);
|
||||
if (decodedHex && decodedHex.length > 0) return true;
|
||||
if (decodedHex && decodedHex.length > 0) {
|
||||
if (hasher.encodeHex(decodedHex) === candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -198,23 +215,7 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||
*/
|
||||
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(candidate);
|
||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return hasher.encode(decoded as number[]) === candidate;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isHashId(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +247,11 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
const num = Number(BigInt('0x' + decodedHex));
|
||||
if (!isNaN(num)) return Math.trunc(num);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return str;
|
||||
@@ -270,6 +276,11 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
const num = Number(BigInt('0x' + decodedHex));
|
||||
if (!isNaN(num)) return Math.trunc(num);
|
||||
}
|
||||
} catch {}
|
||||
const fallback = Number(str);
|
||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||
|
||||
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
|
||||
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;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
|
||||
return list || [];
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const inAppNotifications = useMemo(() => {
|
||||
|
||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
// Remove caching from all pages: zero stale time and zero cache retention
|
||||
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||
const RETAINED_CACHE_OPTIONS = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
@@ -58,31 +60,16 @@ apiClient.interceptors.request.use(
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
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) {
|
||||
const headers = config.headers || {};
|
||||
const hasNoCacheHeader =
|
||||
headers['Cache-Control'] === 'no-cache' ||
|
||||
headers['cache-control'] === 'no-cache' ||
|
||||
headers['Pragma'] === 'no-cache';
|
||||
|
||||
if (hasNoCacheHeader) {
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
} else {
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
||||
config.params.set('_v', String(getMutationEpoch()));
|
||||
}
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
if (!config.params._v && !config.params._t) {
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
config.params._v = getMutationEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
|
||||
Reference in New Issue
Block a user