Optimised cach usage, and enforced maker checker
This commit is contained in:
@@ -186,7 +186,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||||
@@ -194,7 +193,6 @@ export const AppLayout: React.FC = () => {
|
|||||||
queryKey: ['active-tenant-header', tenantId],
|
queryKey: ['active-tenant-header', tenantId],
|
||||||
queryFn: () => getTenant(tenantId!),
|
queryFn: () => getTenant(tenantId!),
|
||||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure current logged-in user is registered in the email cache
|
// Ensure current logged-in user is registered in the email cache
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
|
||||||
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
|
<span>Active / Executed</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
title="Approved evidence is immutable. Status cannot be modified."
|
title="Approved evidence is immutable. Status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
|||||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter active users and exclude the current logged-in user so they can never message themselves
|
// Filter active users and exclude the current logged-in user so they can never message themselves
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export function useInAppNotifications() {
|
|||||||
},
|
},
|
||||||
// Live polling every 30 seconds
|
// Live polling every 30 seconds
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
// Remove caching from all pages: zero stale time and zero cache retention
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 0,
|
||||||
refetchOnReconnect: true,
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always',
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||||
|
const RETAINED_CACHE_OPTIONS = {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
{Number(cr.status) === ApprovalStatus.Approved ? (
|
{Number(cr.status) === ApprovalStatus.Approved ? (
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Resolved & Enacted (Immutable)</span>
|
<span>Resolved & Enacted</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
title="Approved and enacted change requests are immutable."
|
title="Approved and enacted change requests are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Enacted (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Enacted</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||||
import { RulesSection } from '../components/governance/RulesSection';
|
import { RulesSection } from '../components/governance/RulesSection';
|
||||||
@@ -241,7 +242,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||||
@@ -580,7 +580,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -608,6 +608,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||||
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
|
||||||
setIsOrgModalOpen(false);
|
setIsOrgModalOpen(false);
|
||||||
@@ -639,6 +640,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
|
||||||
setIsPortfolioModalOpen(false);
|
setIsPortfolioModalOpen(false);
|
||||||
@@ -650,15 +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({
|
const programmeMutation = useMutation({
|
||||||
mutationFn: (data: ProgrammeFormValues) => {
|
mutationFn: (data: ProgrammeFormValues) => {
|
||||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
||||||
if (editingProgramme) {
|
if (editingProgramme) {
|
||||||
|
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
|
||||||
|
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
|
||||||
|
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
|
||||||
|
}
|
||||||
return updateProgramme({
|
return updateProgramme({
|
||||||
programmeId: editingProgramme.id,
|
programmeId: editingProgramme.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
portfolioId: Number(data.portfolioId),
|
portfolioId: Number(data.portfolioId),
|
||||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
status: targetStatus,
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -669,7 +699,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['programme'] });
|
||||||
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
|
||||||
setIsProgrammeModalOpen(false);
|
setIsProgrammeModalOpen(false);
|
||||||
setEditingProgramme(null);
|
setEditingProgramme(null);
|
||||||
@@ -755,7 +787,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
programmeForm.reset({
|
programmeForm.reset({
|
||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Pending,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerEmail: effectiveUserEmail,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
@@ -833,6 +865,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
status: (p) => getApprovalStatusBadge(p.status).label,
|
status: (p) => getApprovalStatusBadge(p.status).label,
|
||||||
createdAt: (p) => p.createdAt,
|
createdAt: (p) => p.createdAt,
|
||||||
ownedBy: (p) => getUserEmail(p.ownedBy),
|
ownedBy: (p) => getUserEmail(p.ownedBy),
|
||||||
|
createdBy: (p) => getUserEmail(p.createdBy),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export Columns Definitions
|
// Export Columns Definitions
|
||||||
@@ -863,6 +896,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
|
||||||
},
|
},
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
{ header: 'Description', key: 'description', format: (val) => val || '—' },
|
||||||
];
|
];
|
||||||
@@ -881,6 +915,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1382,19 +1417,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
Owner Email
|
Owner Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
|
Created By
|
||||||
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
{loadingProgrammes ? (
|
{loadingProgrammes ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading programmes...
|
Loading programmes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedProgrammes.length === 0 ? (
|
) : sortedProgrammes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
No programmes found matching query.
|
No programmes found matching query.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1402,6 +1440,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
sortedProgrammes.map((pr) => {
|
sortedProgrammes.map((pr) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||||
|
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
|
||||||
return (
|
return (
|
||||||
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5">
|
<td className="py-3.5 px-5">
|
||||||
@@ -1420,8 +1459,49 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
||||||
{getUserEmail(pr.ownedBy)}
|
{getUserEmail(pr.ownedBy)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||||
|
{getUserEmail(pr.createdBy)}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
|
||||||
|
{isApproved ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
|
title="Approved programmes cannot be modified."
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>Approved</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditProgramme(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Edit Programme"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{canApproveRecord('governance', pr.createdBy) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={() => approveProgrammeMutation.mutate(pr)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve Programme"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove('governance') && isMaker(pr.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1458,14 +1538,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<Scale className="w-3 h-3" />
|
<Scale className="w-3 h-3" />
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openEditProgramme(pr)}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3 h-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2004,18 +2076,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
|
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
|
||||||
<div
|
<div
|
||||||
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||||
title="Approved programmes are immutable."
|
title="Approved programmes cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
|
||||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
const isSelf = isMaker(editingProgramme?.createdBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
@@ -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-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
<div className="flex items-center gap-2">
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||||
</span>
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
|
</span>
|
||||||
|
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
|
||||||
|
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={approveProgrammeMutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Approve Programme</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker: Record creator cannot approve
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core Details Grid: Owner & Creation Date */}
|
{/* Core Details Grid: Owner, Maker, Creation Date */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
{/* Creation Date */}
|
{/* Creation Date */}
|
||||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
@@ -2745,6 +2842,17 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Maker */}
|
||||||
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
|
||||||
|
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
|
||||||
|
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics: Total Rules and Total Instructions */}
|
{/* Metrics: Total Rules and Total Instructions */}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { useRbac } from '../auth/rbac';
|
import { useRbac } from '../auth/rbac';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Budget,
|
Budget,
|
||||||
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateAwardMutation = useMutation({
|
const updateAwardMutation = useMutation({
|
||||||
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success('Award Updated', 'Award details saved successfully.');
|
success('Award Updated', 'Award details saved successfully.');
|
||||||
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const budgetMutation = useMutation({
|
const budgetMutation = useMutation({
|
||||||
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget line established', 'Budget line created against project');
|
success('Budget line established', 'Budget line created against project');
|
||||||
setIsBudgetModalOpen(false);
|
setIsBudgetModalOpen(false);
|
||||||
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const updateBudgetMutation = useMutation({
|
const updateBudgetMutation = useMutation({
|
||||||
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
success('Budget Updated', 'Budget compliance and approval status updated.');
|
success('Budget Updated', 'Budget compliance and approval status updated.');
|
||||||
setIsEditBudgetModalOpen(false);
|
setIsEditBudgetModalOpen(false);
|
||||||
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
success(
|
success(
|
||||||
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
|
||||||
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (id: string | number) => approveDisbursement(id),
|
mutationFn: (id: string | number) => approveDisbursement(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
bumpMutationEpoch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
success(
|
success(
|
||||||
@@ -826,8 +832,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchAwards();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['awards'] });
|
||||||
|
await refetchAwards();
|
||||||
success('Refreshed', 'Awards intake list refreshed from API.');
|
success('Refreshed', 'Awards intake list refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingAwards}
|
disabled={isFetchingAwards}
|
||||||
@@ -919,10 +927,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(awd.status) === ApprovalStatus.Approved ? (
|
{Number(awd.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved award is immutable. Details cannot be modified."
|
title="Approved award details cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -973,8 +981,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchBudgets();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
|
await refetchBudgets();
|
||||||
success('Refreshed', 'Budget line categories refreshed from API.');
|
success('Refreshed', 'Budget line categories refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingBudgets}
|
disabled={isFetchingBudgets}
|
||||||
@@ -1059,10 +1069,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(b.status) === ApprovalStatus.Approved ? (
|
{Number(b.status) === ApprovalStatus.Approved ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
|
||||||
title="Approved budget is immutable. Status cannot be modified."
|
title="Approved budget status cannot be modified."
|
||||||
>
|
>
|
||||||
<Lock className="w-3 h-3 text-emerald-400" />
|
<Lock className="w-3 h-3 text-emerald-400" />
|
||||||
<span>Approved (Immutable)</span>
|
<span>Approved</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1133,8 +1143,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
refetchDisbursements();
|
bumpMutationEpoch();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||||||
|
await refetchDisbursements();
|
||||||
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
success('Refreshed', 'Disbursement tranches refreshed from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingDisbursements}
|
disabled={isFetchingDisbursements}
|
||||||
@@ -1549,7 +1561,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
onSubmit={editAwardForm.handleSubmit((values) => {
|
onSubmit={editAwardForm.handleSubmit((values) => {
|
||||||
if (!editingAward) return;
|
if (!editingAward) return;
|
||||||
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
if (Number(editingAward.status) === ApprovalStatus.Approved) {
|
||||||
toastError('Record Immutable', 'Approved awards cannot be modified.');
|
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateAwardMutation.mutate({
|
updateAwardMutation.mutate({
|
||||||
@@ -1630,7 +1642,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
|
||||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
@@ -1783,7 +1795,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
|
||||||
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved (Immutable - Status Cannot Be Changed)</span>
|
<span>Approved</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
const { data: directoryUsers = [] } = useQuery<User[]>({
|
const { data: directoryUsers = [] } = useQuery<User[]>({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const { data: usersDirectory = [] } = useQuery({
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
queryKey: ['users-directory'],
|
queryKey: ['users-directory'],
|
||||||
queryFn: () => getUsers(1, 200),
|
queryFn: () => getUsers(1, 200),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
title="Approved site visits are immutable."
|
title="Approved site visits are immutable."
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Approved & Verified (Immutable - Cannot Be Changed)</span>
|
<span>Approved & Verified</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
|||||||
} = useQuery<TenantOrganisation[]>({
|
} = useQuery<TenantOrganisation[]>({
|
||||||
queryKey: ['tenant-organisations', tenantId],
|
queryKey: ['tenant-organisations', tenantId],
|
||||||
queryFn: () => getTenantOrganisations(tenantId),
|
queryFn: () => getTenantOrganisations(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch tenant users directory to resolve linked user email per organisation
|
// Fetch tenant users directory to resolve linked user email per organisation
|
||||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
|
|||||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||||
queryKey: ['tenant-users', tenantId],
|
queryKey: ['tenant-users', tenantId],
|
||||||
queryFn: () => getTenantUsers(tenantId),
|
queryFn: () => getTenantUsers(tenantId),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { canAdd } = useRbac();
|
const { canAdd } = useRbac();
|
||||||
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
} = useQuery<Tenant[]>({
|
} = useQuery<Tenant[]>({
|
||||||
queryKey: ['tenants-list'],
|
queryKey: ['tenants-list'],
|
||||||
queryFn: () => getTenants(),
|
queryFn: () => getTenants(),
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
|
|||||||
@@ -442,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
|
||||||
|
|
||||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
export const getStoredInstructions = (): Instrument[] => [];
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Instrument[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
export const saveStoredInstruction = (_inst?: Instrument): void => {};
|
||||||
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored instruction duplicates', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredInstructions = (): Instrument[] => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredInstruction = (inst: Instrument): void => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const current = getStoredInstructions();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(i) => String(i.id) === String(inst.id) ||
|
|
||||||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(inst);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored instruction', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||||
let apiItems: Instrument[] = [];
|
|
||||||
let apiSucceeded = false;
|
|
||||||
try {
|
try {
|
||||||
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||||
if (Array.isArray(res.data)) {
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
apiItems = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredInstructionDuplicates(apiItems);
|
|
||||||
return apiItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local items only when API failed
|
|
||||||
const localItems = getStoredInstructions().filter(
|
|
||||||
(item) => String(item.programmeId) === String(programmeId)
|
|
||||||
);
|
|
||||||
return localItems;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInstructions = getInstruments;
|
export const getInstructions = getInstruments;
|
||||||
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
|
|||||||
}
|
}
|
||||||
|
|
||||||
const map = new Map<string, Instrument>();
|
const map = new Map<string, Instrument>();
|
||||||
let hasApiData = false;
|
|
||||||
|
|
||||||
if (progList && progList.length > 0) {
|
if (progList && progList.length > 0) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
progList.map((pr) => getInstruments(pr.id))
|
progList.map((pr) => getInstruments(pr.id))
|
||||||
);
|
);
|
||||||
results.forEach((res) => {
|
results.forEach((res) => {
|
||||||
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||||
hasApiData = true;
|
|
||||||
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
if (hasApiData || map.size > 0) {
|
|
||||||
const allApiInstruments = Array.from(map.values());
|
|
||||||
purgeStoredInstructionDuplicates(allApiInstruments);
|
|
||||||
return allApiInstruments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getStoredInstructions();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInstructions = getAllInstruments;
|
export const getAllInstructions = getAllInstruments;
|
||||||
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
||||||
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredInstructionDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackInstruction: Instrument = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredInstruction(fallbackInstruction);
|
|
||||||
return fallbackInstruction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
code: code,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
||||||
createdBy: payload.userId || 'system',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createInstruction = createInstrument;
|
export const createInstruction = createInstrument;
|
||||||
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
||||||
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
|
||||||
if (res.data && res.data.id) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[updateInstrument] API update failed:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
|
||||||
const updatedInstrument: Instrument = {
|
|
||||||
id: targetId!,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
code: payload.code || existing?.code || null,
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
createdBy: existing?.createdBy || 'system',
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: payload.userId || 'system',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
saveStoredInstruction(updatedInstrument);
|
|
||||||
}
|
|
||||||
if (isApprovedOrActiveStatus(payload.status)) {
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
recordType: 'Funding Instrument',
|
recordType: 'Funding Instrument',
|
||||||
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
title: payload.name,
|
title: payload.name,
|
||||||
}).catch(console.warn);
|
}).catch(console.warn);
|
||||||
}
|
}
|
||||||
return updatedInstrument;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateInstruction = updateInstrument;
|
export const updateInstruction = updateInstrument;
|
||||||
@@ -2246,75 +2116,20 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
|
||||||
|
|
||||||
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
export const getStoredRules = (): Rule[] => [];
|
||||||
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const local: Rule[] = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(local) || local.length === 0) return;
|
|
||||||
|
|
||||||
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
export const saveStoredRule = (_rule?: Rule): void => {};
|
||||||
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
|
||||||
|
|
||||||
const remaining = local.filter(
|
|
||||||
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remaining.length !== local.length) {
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to purge stored rule duplicates', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredRules = (): Rule[] => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveStoredRule = (rule: Rule): void => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const current = getStoredRules();
|
|
||||||
const idx = current.findIndex(
|
|
||||||
(r) => String(r.id) === String(rule.id) ||
|
|
||||||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
|
||||||
} else {
|
|
||||||
current.unshift(rule);
|
|
||||||
}
|
|
||||||
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to save stored rule', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 15. Execution: Programme Rules APIs
|
// 15. Execution: Programme Rules APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
||||||
try {
|
const hashedRuleId = await toolsService.encodeId(ruleId);
|
||||||
const hashedRuleId = await toolsService.encodeId(ruleId);
|
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
||||||
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
if (res.data) {
|
||||||
if (res.data) {
|
return res.data;
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
|
||||||
if (localRule) return localRule;
|
|
||||||
throw new Error('Rule not found');
|
|
||||||
}
|
}
|
||||||
throw new Error('Rule not found');
|
throw new Error('Rule not found');
|
||||||
};
|
};
|
||||||
@@ -2332,38 +2147,16 @@ export const getRules = async (
|
|||||||
pagination: Pagination = { page: 1, pageSize: 100 },
|
pagination: Pagination = { page: 1, pageSize: 100 },
|
||||||
programmeId?: string | number
|
programmeId?: string | number
|
||||||
): Promise<Rule[]> => {
|
): Promise<Rule[]> => {
|
||||||
let apiRules: Rule[] = [];
|
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
||||||
let apiSucceeded = false;
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
||||||
try {
|
page: pagination.page || 1,
|
||||||
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
pageSize: pagination.pageSize || 100,
|
||||||
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
});
|
||||||
page: pagination.page || 1,
|
let result = Array.isArray(res.data) ? res.data : [];
|
||||||
pageSize: pagination.pageSize || 100,
|
|
||||||
});
|
|
||||||
if (Array.isArray(res.data)) {
|
|
||||||
apiRules = res.data;
|
|
||||||
apiSucceeded = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[getRules] Error fetching from backend API:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiSucceeded) {
|
|
||||||
purgeStoredRuleDuplicates(apiRules);
|
|
||||||
let result = apiRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
||||||
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local storage only if API call failed
|
|
||||||
const localRules = getStoredRules();
|
|
||||||
let combined = localRules;
|
|
||||||
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
||||||
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
||||||
}
|
}
|
||||||
return combined;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
||||||
@@ -2375,70 +2168,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
||||||
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
return res.data;
|
||||||
if (res.data && res.data.id) {
|
|
||||||
purgeStoredRuleDuplicates([res.data]);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
|
||||||
const newId = Date.now();
|
|
||||||
const fallbackRule: Rule = {
|
|
||||||
id: newId,
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(fallbackRule);
|
|
||||||
return fallbackRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Date.now(),
|
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: ActivityStatus.Active,
|
|
||||||
version: 1,
|
|
||||||
createdBy: validUserId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
||||||
const validUserId = toValidUuid(payload.userId);
|
const validUserId = toValidUuid(payload.userId);
|
||||||
try {
|
await apiClient.put('/v1/api/execution/rules', {
|
||||||
await apiClient.put('/v1/api/execution/rules', {
|
ruleId: toIntegerId(payload.ruleId),
|
||||||
ruleId: toIntegerId(payload.ruleId),
|
userId: validUserId,
|
||||||
userId: validUserId,
|
name: payload.name,
|
||||||
name: payload.name,
|
description: payload.description,
|
||||||
description: payload.description,
|
status: Number(payload.status),
|
||||||
status: Number(payload.status),
|
version: Number(payload.version || 1),
|
||||||
version: Number(payload.version || 1),
|
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
||||||
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
});
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[updateRule] API update failed:', err);
|
|
||||||
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
|
||||||
const updatedRule: Rule = {
|
|
||||||
id: payload.ruleId,
|
|
||||||
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
||||||
name: payload.name,
|
|
||||||
description: payload.description,
|
|
||||||
status: Number(payload.status),
|
|
||||||
version: Number(payload.version || 1),
|
|
||||||
createdBy: existing?.createdBy || validUserId,
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedBy: validUserId,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveStoredRule(updatedRule);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; version=1',
|
'Content-Type': 'application/json; version=1',
|
||||||
Accept: 'application/json; version=1',
|
Accept: 'application/json; version=1',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
|
|||||||
config.url?.includes('amazonaws.com') ||
|
config.url?.includes('amazonaws.com') ||
|
||||||
config.url?.includes('garage');
|
config.url?.includes('garage');
|
||||||
|
|
||||||
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
|
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||||
|
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||||
const headers = config.headers || {};
|
if (config.params instanceof URLSearchParams) {
|
||||||
const hasNoCacheHeader =
|
config.params.set('_t', String(Date.now()));
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
// Explicit freshness requested: attach fresh timestamp
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
config.params.set('_t', String(Date.now()));
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
config.params._t = Date.now();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Standard GET: attach current mutation epoch
|
config.params = config.params || {};
|
||||||
if (config.params instanceof URLSearchParams) {
|
config.params._t = Date.now();
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
config.params._v = getMutationEpoch();
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
|
|||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedTenantLockout: {
|
|
||||||
key: string;
|
|
||||||
result: TenantLockoutResult;
|
|
||||||
timestamp: number;
|
|
||||||
} | null = null;
|
|
||||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
|
||||||
|
|
||||||
export function clearTenantLockoutCache(): void {
|
export function clearTenantLockoutCache(): void {
|
||||||
cachedTenantLockout = null;
|
// Caching removed to guarantee fresh tenant state on every verification
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStatusActive(status: any): boolean {
|
export function isStatusActive(status: any): boolean {
|
||||||
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
|
|||||||
candidateId = tenantClaim;
|
candidateId = tenantClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
// Fresh check: query endpoint directly without cache
|
||||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
|
||||||
if (
|
|
||||||
cachedTenantLockout.result.isActive &&
|
|
||||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
|
||||||
) {
|
|
||||||
return cachedTenantLockout.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenantRes: Tenant | null = null;
|
let tenantRes: Tenant | null = null;
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
tenant: tenantObj,
|
tenant: tenantObj,
|
||||||
};
|
};
|
||||||
cachedTenantLockout = {
|
// Direct return without caching
|
||||||
key: cacheKey,
|
|
||||||
result: successResult,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
return successResult;
|
return successResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,7 +293,6 @@ export function DashboardPage() {
|
|||||||
queryKey: ['partner-applicant-data', applicantUserId],
|
queryKey: ['partner-applicant-data', applicantUserId],
|
||||||
queryFn: () => getApplicantData(applicantUserId),
|
queryFn: () => getApplicantData(applicantUserId),
|
||||||
enabled: Boolean(applicantUserId),
|
enabled: Boolean(applicantUserId),
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
|
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
|
||||||
@@ -426,7 +425,6 @@ export function DashboardPage() {
|
|||||||
return Object.fromEntries(results);
|
return Object.fromEntries(results);
|
||||||
},
|
},
|
||||||
enabled: partnerSummary.projectEntries.length > 0,
|
enabled: partnerSummary.projectEntries.length > 0,
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine into EnrichedPartnerProject[]
|
// Combine into EnrichedPartnerProject[]
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
|
|||||||
return list || [];
|
return list || [];
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const inAppNotifications = useMemo(() => {
|
const inAppNotifications = useMemo(() => {
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
// Remove caching from all pages: zero stale time and zero cache retention
|
||||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
// Whenever a component needs data, it must call its corresponding endpoint(s)
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 0,
|
||||||
refetchOnReconnect: true,
|
gcTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always',
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retain caching ONLY for sync status, auth token, roles and grants
|
||||||
|
const RETAINED_CACHE_OPTIONS = {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
|
||||||
|
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; version=1',
|
'Content-Type': 'application/json; version=1',
|
||||||
Accept: 'application/json; version=1',
|
Accept: 'application/json; version=1',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
@@ -58,31 +60,16 @@ apiClient.interceptors.request.use(
|
|||||||
config.url?.includes('amazonaws.com') ||
|
config.url?.includes('amazonaws.com') ||
|
||||||
config.url?.includes('garage');
|
config.url?.includes('garage');
|
||||||
|
|
||||||
|
// Always attach freshness timestamp and mutation epoch to internal GET requests
|
||||||
|
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
|
||||||
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
|
||||||
const headers = config.headers || {};
|
if (config.params instanceof URLSearchParams) {
|
||||||
const hasNoCacheHeader =
|
config.params.set('_t', String(Date.now()));
|
||||||
headers['Cache-Control'] === 'no-cache' ||
|
config.params.set('_v', String(getMutationEpoch()));
|
||||||
headers['cache-control'] === 'no-cache' ||
|
|
||||||
headers['Pragma'] === 'no-cache';
|
|
||||||
|
|
||||||
if (hasNoCacheHeader) {
|
|
||||||
if (config.params instanceof URLSearchParams) {
|
|
||||||
config.params.set('_t', String(Date.now()));
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
config.params._t = Date.now();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (config.params instanceof URLSearchParams) {
|
config.params = config.params || {};
|
||||||
if (!config.params.has('_v') && !config.params.has('_t')) {
|
config.params._t = Date.now();
|
||||||
config.params.set('_v', String(getMutationEpoch()));
|
config.params._v = getMutationEpoch();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.params = config.params || {};
|
|
||||||
if (!config.params._v && !config.params._t) {
|
|
||||||
config.params._v = getMutationEpoch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user