Optimised cach usage, and enforced maker checker
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,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';
|
||||
@@ -241,7 +242,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[]>({
|
||||
@@ -580,7 +580,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,
|
||||
},
|
||||
});
|
||||
@@ -608,6 +608,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||
setIsOrgModalOpen(false);
|
||||
@@ -639,6 +640,7 @@ export const GovernancePage: React.FC = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
bumpMutationEpoch();
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||
setIsPortfolioModalOpen(false);
|
||||
@@ -650,15 +652,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 +699,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);
|
||||
@@ -755,7 +787,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 +865,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 +896,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 +915,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 (
|
||||
@@ -1382,19 +1417,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 +1440,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 +1459,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 +1538,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>
|
||||
@@ -2004,18 +2076,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
|
||||
@@ -2717,13 +2789,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 +2842,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -293,7 +293,6 @@ export function DashboardPage() {
|
||||
queryKey: ['partner-applicant-data', applicantUserId],
|
||||
queryFn: () => getApplicantData(applicantUserId),
|
||||
enabled: Boolean(applicantUserId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
|
||||
@@ -426,7 +425,6 @@ export function DashboardPage() {
|
||||
return Object.fromEntries(results);
|
||||
},
|
||||
enabled: partnerSummary.projectEntries.length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Combine into EnrichedPartnerProject[]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user