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
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
import { NotificationBell } from './NotificationBell';
import {
Inbox,
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
if (collapsedSections[path] !== undefined) {
return collapsedSections[path];
}
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
return false;
}
return false;
};
const toggleSection = (path: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCollapsedSections((prev) => ({
...prev,
[path]: !isSectionCollapsed(path),
}));
};
const handleLogout = async () => {
await logout();
@@ -344,13 +365,16 @@ export const AppLayout: React.FC = () => {
);
}
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
return (
<div key={item.path} className="space-y-1">
<div className="flex items-center gap-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
@@ -361,9 +385,25 @@ export const AppLayout: React.FC = () => {
<Icon className="w-4.5 h-4.5 shrink-0" />
<span className="truncate flex-1">{item.name}</span>
</NavLink>
{hasSub && (
<button
type="button"
onClick={(e) => toggleSection(item.path, e)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
}`}
/>
</button>
)}
</div>
{/* Sub-items under parent item */}
{hasSub && (
{hasSub && !isCollapsed && (
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
{item.subItems?.map((subItem) => {
const SubIcon = subItem.icon;
@@ -37,7 +37,7 @@ const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
milestoneId: z.string().optional().nullable(),
performedAt: z.string().optional().nullable(),
});
@@ -20,6 +20,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
} from '../../services/api';
import { Project, ApprovalStatus, Budget } from '../../types';
import {
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', projectId],
queryFn: () => getDisbursements(1, 100),
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
return (
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
Approved Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`text-xs font-mono font-semibold shrink-0 ${
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
}`}
title="Approved Budget minus Net Project Value"
>
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
</span>
</>
)}
</div>
@@ -33,6 +33,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
updateProjectStatus,
} from '../../services/api';
import { Project, ApprovalStatus, Programme } from '../../types';
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
// Milestone Progress
// Milestone Progress & Disbursements
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', project?.id],
queryFn: () => getDisbursements(1, 100),
enabled: !!project?.id,
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
// Schedule Risk
const scheduleRisk = useMemo(() => {
if (!project) return null;
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-blue-400 font-mono font-bold">
Project Value: {formatZAR(totalActivityBudget)}
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
approvedBudgetTotal - totalActivityBudget >= 0
projectVariance >= 0
? '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 minus Total Project Value"
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
>
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
Variance: {projectVariance >= 0 ? '+' : ''}
{formatZAR(projectVariance)}
</span>
</div>
</div>
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids';
import { useAuth } from '../../auth/AuthProvider';
import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { user: currentAuthUser } = useAuth();
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const userId = user?.id || '';
const isSelf =
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
const canManageRolesAndClaims =
canApprove('settings_users') ||
canApprove('tenant_management') ||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
hasTenantSelfHelpAccess ||
isUnrestricted;
const canChangeStatus =
canAdd('settings_users') ||
!isSelf &&
(canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted;
isUnrestricted);
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
const userId = user?.id || '';
// 1. Fetch fresh user details
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
queryKey: ['user-detail', userId],
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
});
const statusMutation = useMutation({
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
mutationFn: (newStatus: ActivityStatus) => {
if (isSelf) {
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
}
return updateUserStatus(userId, newStatus);
},
onSuccess: () => {
success('Status Updated', 'User activity status changed.');
refetchUser();
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span>
</div>
<div className="flex items-center gap-2">
{effectiveUser?.status === ActivityStatus.Active ? (
{isSelf ? (
<span
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
title="Maker-Checker policy: Administrators cannot change their own account activity status"
>
Self-Account (Protected)
</span>
) : effectiveUser?.status === ActivityStatus.Active ? (
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
@@ -6,6 +6,7 @@ import {
getNotifications,
createNotification,
markNotificationAsRead,
archiveNotification,
updateNotification,
getCurrentUserTenantId,
} from '../services/api';
@@ -136,7 +137,10 @@ export function useInAppNotifications() {
// Active in-app notifications in inbox (excluding trashed and drafts)
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
n =>
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
@@ -167,7 +171,9 @@ export function useInAppNotifications() {
// Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read
@@ -210,8 +216,8 @@ export function useInAppNotifications() {
});
}, [starredStorageKey]);
// Delete message (move to trash)
const deleteMessage = useCallback((id: string | number) => {
// Delete message (NotificationStatus == Archived & move to trash)
const deleteMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
@@ -221,10 +227,22 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
// Restore from trash
const restoreMessage = useCallback((id: string | number) => {
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
);
try {
await archiveNotification(id);
} catch (err) {
console.warn('[deleteMessage] Error archiving notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Restore from trash / unarchive
const restoreMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
@@ -234,7 +252,19 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
);
try {
await markNotificationAsRead(id);
} catch (err) {
console.warn('[restoreMessage] Error restoring notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Permanent delete
const permanentDelete = useCallback((id: string | number) => {
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Archived:
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
@@ -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}
{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}
{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('');
if (inviteAutoApprove) {
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
storeInvitePlatform(invId, variables.platform);
if (inviteAutoApprove) {
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]">
<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"
>
{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>
<div className="flex items-center gap-1 mb-1.5">
<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 UUID *
Recipient User *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
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'}
@@ -1514,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
awardId: toSafeInt64String(payload.awardId),
contractId: toSafeInt64String(payload.contractId),
projectId: toSafeInt64String(payload.projectId || 0),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
@@ -1601,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
@@ -1623,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
};
// ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
@@ -29,6 +29,7 @@ export enum NotificationStatus {
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
export enum NotificationPlatform {
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
export interface UpdateContractRequest {
contractId: number | string;
projectId?: number | string;
status: ContractStatus;
}
export interface ExecuteContractRequest {
contractId: number | string;
projectId?: number | string;
externalSignatureId: string;
signedDocumentUrl: string;
}
@@ -838,6 +841,7 @@ export interface Invite {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
createdBy: string;
updatedBy?: string | null;
@@ -852,6 +856,7 @@ export interface InviteRequest {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
message: string;
platform: NotificationPlatform;
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = rawVal ?? '';
}
if (typeof formatted === 'string') {
// Strip out line breaks and carriage returns that distort Excel row heights and layout
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
}
rowObj[col.header] = formatted;
});
return rowObj;
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = String(rawVal ?? '');
}
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return Math.max(max, formatted.length);
}, headerLen);
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
});
worksheet['!cols'] = colWidths;
+1
View File
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
public enum NotificationPlatform : int
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Search,
Trash2,
Bell,
LogOut,
Calendar,
@@ -197,6 +198,7 @@ export function DashboardPage() {
top5Unread,
markAsRead,
markAllAsRead,
deleteMessage,
sendMessage,
isSending,
} = useInAppNotifications();
@@ -1221,9 +1223,25 @@ export function DashboardPage() {
</div>
</div>
</div>
<span className="text-xs text-slate-400/70 shrink-0">
<div className="flex items-center gap-2.5 shrink-0">
<span className="text-xs text-slate-400/70">
{formatFullDate(msg.createdAt)}
</span>
<button
type="button"
onClick={e => {
e.stopPropagation();
deleteMessage(msg.id);
if (selectedNotification?.id === msg.id) {
setSelectedNotification(null);
}
}}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition cursor-pointer"
title="Delete message (Archive)"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})
@@ -1905,8 +1923,9 @@ export function DashboardPage() {
/>
</div>
{/* Action Buttons: Reply and Close */}
{/* Action Buttons: Reply, Delete, and Close */}
<div className="flex items-center justify-between pt-2 border-t border-white/[0.08]">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleReplyToMessage(selectedNotification)}
@@ -1915,6 +1934,19 @@ export function DashboardPage() {
<Reply className="w-3.5 h-3.5" />
<span>Reply</span>
</button>
<button
type="button"
onClick={() => {
deleteMessage(selectedNotification.id);
setSelectedNotification(null);
}}
className="px-4 py-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 text-xs font-semibold flex items-center gap-2 transition-all cursor-pointer"
title="Delete message (Archive)"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Delete</span>
</button>
</div>
<button
type="button"
onClick={() => setSelectedNotification(null)}
@@ -5,6 +5,8 @@ import {
getUserNotifications,
getNotifications,
createNotification,
updateNotification,
archiveNotification,
markNotificationAsRead,
getCurrentUserTenantId,
resolveUserIdByEmail,
@@ -124,7 +126,10 @@ export function useInAppNotifications() {
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
n =>
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
@@ -151,7 +156,9 @@ export function useInAppNotifications() {
}, [inAppNotifications, starredIds, trashedIds]);
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
const markAsReadMutation = useMutation({
@@ -191,7 +198,8 @@ export function useInAppNotifications() {
});
}, [starredStorageKey]);
const deleteMessage = useCallback((id: string | number) => {
// Delete message (NotificationStatus == Archived & move to trash)
const deleteMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
@@ -201,9 +209,22 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
const restoreMessage = useCallback((id: string | number) => {
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
);
try {
await archiveNotification(id);
} catch (err) {
console.warn('[deleteMessage] Error archiving notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Restore from trash / unarchive
const restoreMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
@@ -213,7 +234,19 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
);
try {
await markNotificationAsRead(id);
} catch (err) {
console.warn('[restoreMessage] Error restoring notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
@@ -93,6 +93,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -536,6 +537,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Archived:
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
@@ -109,6 +109,7 @@ import {
UpdateRuleRequest,
Pagination,
Notification,
NotificationPlatform,
NotificationStatus,
Role,
Claim,
@@ -1898,7 +1899,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
@@ -1920,6 +1923,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
};
// ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
@@ -29,6 +29,7 @@ export enum NotificationStatus {
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
export enum NotificationPlatform {