Fixed UI misalignments

This commit is contained in:
2026-10-26 19:57:32 +02:00
parent 80c798e5e6
commit 9f132eeb29
22 changed files with 860 additions and 214 deletions
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!editingComp) throw new Error('No item selected');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingComp.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
}
return updateComplianceItem({
complianceItemId: editingComp.id,
title: vals.title,
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateOutcomeMutation = useMutation({
mutationFn: (vals: OutcomeFormValues) => {
if (!editingOutcome) throw new Error('No outcome selected for update');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingOutcome.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
}
return updateOutcome({
outcomeId: editingOutcome.id,
name: vals.name,
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
{...compForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
{...outcomeForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -597,7 +597,7 @@ export const GovernancePage: React.FC = () => {
organisationUser: '',
idNumber: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
status: ActivityStatus.Inactive,
},
});
@@ -779,7 +779,7 @@ export const GovernancePage: React.FC = () => {
organisationUser: defaultUser?.id || '',
idNumber: defaultUser?.idNumber || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
status: ActivityStatus.Inactive,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
@@ -124,7 +124,7 @@ export const GrantsPage: React.FC = () => {
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
@@ -643,8 +643,14 @@ export const GrantsPage: React.FC = () => {
handleSort: handleAwardSort,
} = useTableSort(filteredAwards, null, null, {
referenceNo: (a) => a.referenceNo,
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
organisationId: (a) => {
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
},
programmeId: (a) => {
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
},
amount: (a) => a.amount,
status: (a) => getApprovalStatusBadge(a.status).label,
createdAt: (a) => a.createdAt,
@@ -657,7 +663,10 @@ export const GrantsPage: React.FC = () => {
handleSort: handleBudgetSort,
} = useTableSort(filteredBudgets, null, null, {
name: (b) => b.name,
projectId: (b) => b.projectId,
projectId: (b) => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
},
amount: (b) => b.amount,
createdAt: (b) => b.createdAt,
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
@@ -671,8 +680,15 @@ export const GrantsPage: React.FC = () => {
handleSort: handleDisbSort,
} = useTableSort(filteredDisbursements, null, null, {
id: (d) => d.id,
projectId: (d) => d.projectId,
milestoneId: (d) => d.milestoneId || 0,
projectId: (d) => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
},
milestoneId: (d) => {
if (!d.milestoneId) return 'Advance Tranche';
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
},
amount: (d) => d.amount,
paymentReference: (d) => d.paymentReference || '',
status: (d) => getApprovalStatusBadge(d.status).label,
@@ -907,10 +923,28 @@ export const GrantsPage: React.FC = () => {
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-200 font-medium">
{awd.organisationName || `Org #${awd.organisationId}`}
{(() => {
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
return (
<div>
<span className="text-white block font-medium">{orgName}</span>
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400">
{awd.programmeName || `Programme #${awd.programmeId}`}
{(() => {
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
return (
<div>
<span className="text-slate-200 block">{progName}</span>
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(awd.amount)}
@@ -1014,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
Budget Line Category
</SortableTh>
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR)
@@ -1053,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
<span className="font-semibold text-white block">{b.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
const projName = proj?.name || `Project #${b.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
{formatZAR(b.amount)}
</td>
@@ -1184,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
Disbursement ID
</SortableTh>
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Milestone Association
@@ -1223,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 font-mono font-bold text-white">
DSB-{d.id}
</td>
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
const projName = proj?.name || `Project #${d.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400 font-medium">
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
{(() => {
if (!d.milestoneId) {
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
}
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
const msName = ms?.name || `Milestone #${d.milestoneId}`;
return (
<div>
<span className="font-medium block">{msName}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(d.amount)}
@@ -1564,6 +1632,13 @@ export const GrantsPage: React.FC = () => {
toastError('Record Approved', 'Approved awards cannot be modified.');
return;
}
if (
selectedAwardStatus === ApprovalStatus.Approved &&
!canApproveRecord('grant_financials', editingAward.createdBy)
) {
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
return;
}
updateAwardMutation.mutate({
awardId: editingAward.id,
organisationId: values.organisationId,
@@ -1652,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending Approval</option>
<option value={ApprovalStatus.Review}>Under Review</option>
<option value={ApprovalStatus.Approved}>Approved &amp; Activated</option>
{(() => {
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
const isSelf = isMaker(editingAward?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveAwd}>
Approved &amp; Activated {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
)}
@@ -148,7 +148,11 @@ export const InboxPage: React.FC = () => {
}
}
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
setSelectedMessage(folderMessages[0]);
const first = folderMessages[0];
setSelectedMessage(first);
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
markAsRead(first.id);
}
}
}, [targetIdParam, folderMessages, activeFolder]);
@@ -665,6 +669,22 @@ export const InboxPage: React.FC = () => {
>
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
</button>
<button
type="button"
onClick={e => {
e.stopPropagation();
deleteMessage(item.id);
if (selectedMessage?.id === item.id) {
setSelectedMessage(null);
}
}}
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
title="Delete message (Archive)"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(item.createdAt)}
</span>
@@ -42,6 +42,7 @@ import {
recordIndicatorActual,
getBudgets,
getActivities,
getDisbursements,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
queryFn: () => getBudgets(1, 100),
});
const { data: allDisbursements = [] } = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(1, 200),
});
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
queryFn: async () => {
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
return map;
}, [allBudgets]);
// Disbursed capital per project
const disbursedByProject = useMemo(() => {
const map = new Map<string | number, number>();
allDisbursements.forEach((d) => {
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
const current = map.get(d.projectId) || 0;
map.set(d.projectId, current + (Number(d.amount) || 0));
}
});
return map;
}, [allDisbursements]);
// Filtered Projects
const filteredProjects = useMemo(() => {
return projects.filter((p) => {
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
return amt > 0 ? formatZAR(amt) : 'R 0';
},
},
{
header: 'Project Variance (ZAR)',
key: 'id',
format: (_, p) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
},
},
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
// Table Sorting
const customExtractors = {
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
if (risk.isAtRisk) return 2;
return 3;
},
variance: (p: Project) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
return approvedBudget - projectValue;
},
};
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
<div className="flex items-center justify-end gap-1">
<span>Project Variance</span>
<HelpTooltip
title="Project Variance"
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
size="sm"
placement="bottom"
/>
</div>
</th>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
</tr>
</thead>
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pVariance = approvedBudget - pActsTotal;
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
const isPos = pVariance >= 0;
return (
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
>
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
</span>
@@ -57,6 +57,7 @@ import {
getOrganisations,
getAwards,
getContracts,
getProjects,
getAuditLogs,
getNotifications,
notifyInviteApproved,
@@ -79,6 +80,8 @@ import {
NotificationStatus,
NotificationPlatform,
ActivityStatus,
ApprovalStatus,
ContractStatus,
User,
Invite,
InviteRequest,
@@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
const { canView, canAdd, canExport } = useRbac();
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
const canViewInvites = canView('settings_invites');
const canViewNotifications = canView('settings_notifications');
@@ -240,6 +243,7 @@ export const SettingsPage: React.FC = () => {
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
const [inviteContractId, setInviteContractId] = useState<string | number>('');
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
@@ -252,6 +256,10 @@ export const SettingsPage: React.FC = () => {
);
const handleApproveInvite = async (inv: Invite, idx: number) => {
if (isMaker(inv.createdBy)) {
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
return;
}
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
@@ -269,32 +277,6 @@ export const SettingsPage: React.FC = () => {
);
};
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
const matchPlatform =
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 50),
@@ -310,6 +292,104 @@ export const SettingsPage: React.FC = () => {
queryFn: () => getContracts(1, 50),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(1, 100),
});
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
const eligibleAwards = useMemo(() => {
return awards.filter((aw) => {
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
if (!isApprovedAward) return false;
const hasApprovedContract = contracts.some(
(c) =>
String(c.awardId) === String(aw.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
return hasApprovedContract;
});
}, [awards, contracts]);
const handleSelectAward = (awardId: string | number) => {
setInviteAwardId(awardId);
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
if (selected) {
setInvitedOrgId(selected.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(selected.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(selected.projectId || '');
}
};
React.useEffect(() => {
if (isSendInviteModalOpen) {
if (eligibleAwards.length > 0) {
const currentAward =
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
setInviteAwardId(currentAward.id);
setInvitedOrgId(currentAward.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(currentAward.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(currentAward.projectId || '');
} else {
setInviteAwardId('');
setInvitedOrgId('');
setInviteContractId('');
setInviteProjectId('');
}
}
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
if (!inviteId) return undefined;
try {
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
} catch {
return undefined;
}
};
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
try {
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
} catch {}
};
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
if (!userIdOrUuid) return '—';
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
const email = u?.email || getUserEmail(userIdOrUuid);
return email && email !== '—' ? email : String(userIdOrUuid);
};
const sendInviteMutation = useMutation({
mutationFn: (payload: InviteRequest) => sendInvite(payload),
onSuccess: (data, variables) => {
@@ -319,8 +399,10 @@ export const SettingsPage: React.FC = () => {
setInviteRecipientUuid('');
setInviteMessage('');
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
storeInvitePlatform(invId, variables.platform);
if (inviteAutoApprove) {
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
markInviteAsApprovedInStorage(invId, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
@@ -332,6 +414,7 @@ export const SettingsPage: React.FC = () => {
organisationId: variables.organisationId,
awardId: variables.awardId,
contractId: variables.contractId,
projectId: variables.projectId,
message: variables.message,
},
}).then(() => {
@@ -417,6 +500,38 @@ export const SettingsPage: React.FC = () => {
handleSort: handleNotifSort,
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
if (inv.platform !== undefined && inv.platform !== null) {
return Number(inv.platform) as NotificationPlatform;
}
const stored = getStoredInvitePlatform(inv.id);
if (stored !== undefined) return stored;
const linkedNotif = fetchedNotifications.find(
(n) =>
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
);
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
return Number(linkedNotif.platform) as NotificationPlatform;
}
return NotificationPlatform.Email;
};
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
recipientEmail.includes(inviteSearchTerm.toLowerCase());
const resolvedPlatform = resolveInvitePlatform(inv);
const matchPlatform =
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
// ==========================================
// 4. Audit Log Ledger State
// ==========================================
@@ -485,9 +600,22 @@ export const SettingsPage: React.FC = () => {
{
header: 'Changes / Arguments',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
format: (val) => {
if (!val) return '—';
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{
header: 'Error Details',
key: 'error',
format: (val) => {
if (!val) return '—';
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
];
const uniqueAuditEntities = Array.from(
@@ -557,13 +685,25 @@ export const SettingsPage: React.FC = () => {
];
const inviteExportColumns: ExportColumn<Invite>[] = [
{ header: 'Recipient (UUID)', key: 'recipient' },
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
{ header: 'Invitation Message', key: 'message' },
{
header: 'Recipient Email',
key: 'recipient',
format: (val) => resolveUserEmail(val),
},
{
header: 'Channel / Platform',
key: 'platform',
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
},
{
header: 'Target Organisation / Grant',
key: 'invitedOrganisationId',
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
format: (val, inv) => {
const orgId = val || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
return `${orgName} • Award #${inv.awardId || '—'}`;
},
},
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
];
@@ -589,7 +729,15 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(filteredInvites, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
invitedOrganisationId: (inv: Invite) => {
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
return org?.name || `Organisation #${orgId || ''}`;
},
});
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -1180,14 +1328,11 @@ export const SettingsPage: React.FC = () => {
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
Recipient (UUID)
Recipient Email
</SortableTh>
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Channel
</SortableTh>
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Message
</SortableTh>
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Target Org / Grant
</SortableTh>
@@ -1205,13 +1350,13 @@ export const SettingsPage: React.FC = () => {
<tbody className="divide-y divide-slate-800/50">
{loadingInvites ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading invitations ledger...
</td>
</tr>
) : sortedInvites.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
@@ -1221,22 +1366,34 @@ export const SettingsPage: React.FC = () => {
approvedInviteIds.includes(String(inv.id)) ||
approvedInviteIds.includes(`inv_${idx}`) ||
approvedInviteIds.includes(String(idx));
const resolvedPlatform = resolveInvitePlatform(inv);
const userEmail = resolveUserEmail(inv.recipient);
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
const isCreator = isMaker(inv.createdBy);
return (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{inv.recipient}
<td className="py-3.5 px-5 text-slate-200 text-xs">
<div className="font-medium text-white flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{userEmail}</span>
</div>
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
{inv.recipient}
</div>
</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
{getNotificationPlatformLabel(resolvedPlatform)}
</span>
</td>
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
{inv.message}
</td>
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
Org #{inv.invitedOrganisationId || inv.organisationId} &bull; Award #{inv.awardId}
<td className="py-3.5 px-4 text-slate-300 text-xs">
<div className="font-medium text-white">{orgName}</div>
<div className="text-slate-400 text-[11px] mt-0.5">
Award #{inv.awardId || '—'}
</div>
</td>
<td className="py-3.5 px-4">
{isApproved ? (
@@ -1260,7 +1417,7 @@ export const SettingsPage: React.FC = () => {
<Check className="w-3 h-3" />
Partner Notified
</span>
) : (
) : canApproveRecord('settings_invites', inv.createdBy) ? (
<button
type="button"
onClick={() => handleApproveInvite(inv, idx)}
@@ -1270,6 +1427,17 @@ export const SettingsPage: React.FC = () => {
<Check className="w-3 h-3" />
<span>Approve</span>
</button>
) : isCreator ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
title="Maker-Checker: You created this invite and cannot approve it"
>
Maker (Creator)
</span>
) : (
<span className="text-[11px] text-slate-500">
Pending Review
</span>
)}
</td>
</tr>
@@ -2490,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
<form
onSubmit={(e) => {
e.preventDefault();
if (!inviteAwardId) {
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
return;
}
if (!inviteRecipientUuid || !inviteMessage) {
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
return;
}
sendInviteMutation.mutate({
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
awardId: inviteAwardId,
contractId: inviteContractId,
projectId: inviteProjectId || 0,
recipient: inviteRecipientUuid,
message: inviteMessage,
platform: invitePlatform,
@@ -2506,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
}}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
{eligibleAwards.length === 0 ? (
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
<div>
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved &amp; Active</strong>.
</span>
</div>
</div>
) : null}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Recipient User *
</label>
<HelpTooltip
title="Recipient Directory Identifier"
content="Select a registered recipient user from directory or enter their unique user UUID."
size="sm"
placement="right"
/>
</div>
{usersDirectory.length > 0 && (
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
)}
</div>
{usersDirectory.length > 0 && (
<select
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
onChange={(e) => {
if (e.target.value) setInviteRecipientUuid(e.target.value);
}}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
>
<option value="">-- Choose from Registered Users --</option>
{usersDirectory.map((u) => (
<option key={u.id} value={u.id}>
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
</option>
))}
</select>
)}
<input
type="text"
value={inviteRecipientUuid}
@@ -2528,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Award *
</label>
<HelpTooltip
title="Approved Award Required"
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => handleSelectAward(e.target.value)}
disabled={eligibleAwards.length === 0}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
required
>
{eligibleAwards.length === 0 ? (
<option value="">No approved awards available</option>
) : (
eligibleAwards.map((aw) => {
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
return (
<option key={aw.id} value={aw.id}>
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
</option>
);
})
)}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Contract *
</label>
<HelpTooltip
title="Associated Approved Contract"
content="The active/approved contract linked to the selected award."
size="sm"
placement="top"
/>
</div>
<input
type="text"
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Target Organisation (Recipient Org)
</label>
</div>
<input
type="text"
value={(() => {
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Project
</label>
</div>
<input
type="text"
value={(() => {
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
@@ -2557,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
Originating Authority
</label>
<HelpTooltip
title="Inviting Authority"
@@ -2580,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{awards.map((aw) => (
<option key={aw.id} value={aw.id}>
Award #{aw.id}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{contracts.map((ctr) => (
<option key={ctr.id} value={ctr.id}>
Contract #{ctr.id}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
@@ -2680,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
</button>
<button
type="submit"
disabled={sendInviteMutation.isPending}
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}