Fixed UI misalignments
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { NotificationBell } from './NotificationBell';
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
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 () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -344,26 +365,45 @@ export const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.path} className="space-y-1">
|
<div key={item.path} className="space-y-1">
|
||||||
<NavLink
|
<div className="flex items-center gap-1">
|
||||||
to={item.path}
|
<NavLink
|
||||||
end={hasSub}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
end={hasSub}
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
className={({ isActive }) =>
|
||||||
isActive
|
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
isActive
|
||||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
? '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'
|
||||||
}
|
}`
|
||||||
title={item.name}
|
}
|
||||||
>
|
title={item.name}
|
||||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
>
|
||||||
<span className="truncate flex-1">{item.name}</span>
|
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||||
</NavLink>
|
<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 */}
|
{/* 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">
|
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||||
{item.subItems?.map((subItem) => {
|
{item.subItems?.map((subItem) => {
|
||||||
const SubIcon = subItem.icon;
|
const SubIcon = subItem.icon;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const activitySchema = z.object({
|
|||||||
title: z.string().min(3, 'Activity title required'),
|
title: z.string().min(3, 'Activity title required'),
|
||||||
description: z.string().min(5, 'Description required'),
|
description: z.string().min(5, 'Description required'),
|
||||||
category: z.string().min(2, 'Category 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(),
|
milestoneId: z.string().optional().nullable(),
|
||||||
performedAt: z.string().optional().nullable(),
|
performedAt: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
);
|
);
|
||||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
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 verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
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 totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
const projectValue = totalActivityBudget - totalDisbursed;
|
||||||
|
const projectVariance = approvedBudgetTotal - projectValue;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-slate-600">•</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">•</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>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getDisbursements,
|
||||||
updateProjectStatus,
|
updateProjectStatus,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
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);
|
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 verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
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 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
|
// Schedule Risk
|
||||||
const scheduleRisk = useMemo(() => {
|
const scheduleRisk = useMemo(() => {
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|||||||
Budget: {formatZAR(approvedBudgetTotal)}
|
Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span className="text-blue-400 font-mono font-bold">
|
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||||
Project Value: {formatZAR(totalActivityBudget)}
|
Project Value: {formatZAR(projectValue)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span
|
<span
|
||||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
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-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-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 ? '+' : ''}
|
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
{formatZAR(projectVariance)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
|||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||||
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { user: currentAuthUser } = useAuth();
|
||||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
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 =
|
const canManageRolesAndClaims =
|
||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
canApprove('tenant_management') ||
|
canApprove('tenant_management') ||
|
||||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
hasTenantSelfHelpAccess ||
|
hasTenantSelfHelpAccess ||
|
||||||
isUnrestricted;
|
isUnrestricted;
|
||||||
const canChangeStatus =
|
const canChangeStatus =
|
||||||
canAdd('settings_users') ||
|
!isSelf &&
|
||||||
canAdd('tenant_management') ||
|
(canAdd('settings_users') ||
|
||||||
canAdd('tenant_self_service') ||
|
canAdd('tenant_management') ||
|
||||||
canApprove('settings_users') ||
|
canAdd('tenant_self_service') ||
|
||||||
isUnrestricted;
|
canApprove('settings_users') ||
|
||||||
|
isUnrestricted);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||||
|
|
||||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||||
|
|
||||||
const userId = user?.id || '';
|
|
||||||
|
|
||||||
// 1. Fetch fresh user details
|
// 1. Fetch fresh user details
|
||||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||||
queryKey: ['user-detail', userId],
|
queryKey: ['user-detail', userId],
|
||||||
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
success('Status Updated', 'User activity status changed.');
|
success('Status Updated', 'User activity status changed.');
|
||||||
refetchUser();
|
refetchUser();
|
||||||
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
createNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
|
archiveNotification,
|
||||||
updateNotification,
|
updateNotification,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -136,7 +137,10 @@ export function useInAppNotifications() {
|
|||||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
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]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
@@ -167,7 +171,9 @@ export function useInAppNotifications() {
|
|||||||
|
|
||||||
// Trashed notifications (In-App only)
|
// Trashed notifications (In-App only)
|
||||||
const trashedNotifications = useMemo(() => {
|
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]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
// Mutation to mark notification as read
|
// Mutation to mark notification as read
|
||||||
@@ -210,8 +216,8 @@ export function useInAppNotifications() {
|
|||||||
});
|
});
|
||||||
}, [starredStorageKey]);
|
}, [starredStorageKey]);
|
||||||
|
|
||||||
// Delete message (move to trash)
|
// Delete message (NotificationStatus == Archived & move to trash)
|
||||||
const deleteMessage = useCallback((id: string | number) => {
|
const deleteMessage = useCallback(async (id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||||
@@ -221,10 +227,22 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [trashStorageKey]);
|
|
||||||
|
|
||||||
// Restore from trash
|
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||||
const restoreMessage = useCallback((id: string | number) => {
|
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);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.filter(x => x !== strId);
|
const next = prev.filter(x => x !== strId);
|
||||||
@@ -234,7 +252,19 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
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
|
// Permanent delete
|
||||||
const permanentDelete = useCallback((id: string | number) => {
|
const permanentDelete = useCallback((id: string | number) => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
|||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
|
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case NotificationStatus.Archived:
|
||||||
|
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||||
case NotificationStatus.Draft:
|
case NotificationStatus.Draft:
|
||||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||||
case NotificationStatus.Delivered:
|
case NotificationStatus.Delivered:
|
||||||
|
|||||||
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateCompMutation = useMutation({
|
const updateCompMutation = useMutation({
|
||||||
mutationFn: (vals: ComplianceFormValues) => {
|
mutationFn: (vals: ComplianceFormValues) => {
|
||||||
if (!editingComp) throw new Error('No item selected');
|
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({
|
return updateComplianceItem({
|
||||||
complianceItemId: editingComp.id,
|
complianceItemId: editingComp.id,
|
||||||
title: vals.title,
|
title: vals.title,
|
||||||
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
const updateOutcomeMutation = useMutation({
|
const updateOutcomeMutation = useMutation({
|
||||||
mutationFn: (vals: OutcomeFormValues) => {
|
mutationFn: (vals: OutcomeFormValues) => {
|
||||||
if (!editingOutcome) throw new Error('No outcome selected for update');
|
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({
|
return updateOutcome({
|
||||||
outcomeId: editingOutcome.id,
|
outcomeId: editingOutcome.id,
|
||||||
name: vals.name,
|
name: vals.name,
|
||||||
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...compForm.register('status')}
|
{...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"
|
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) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...outcomeForm.register('status')}
|
{...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"
|
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) => (
|
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
|
||||||
<option key={opt.value} value={opt.value}>
|
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
|
||||||
{opt.label}
|
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
|
||||||
|
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
organisationUser: '',
|
organisationUser: '',
|
||||||
idNumber: '',
|
idNumber: '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
organisationUser: defaultUser?.id || '',
|
organisationUser: defaultUser?.id || '',
|
||||||
idNumber: defaultUser?.idNumber || '',
|
idNumber: defaultUser?.idNumber || '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Inactive,
|
||||||
});
|
});
|
||||||
setIsQuickAddUserOpen(false);
|
setIsQuickAddUserOpen(false);
|
||||||
setQuickUserError(null);
|
setQuickUserError(null);
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||||
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
|
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 [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||||||
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
|
||||||
|
|
||||||
@@ -643,8 +643,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleAwardSort,
|
handleSort: handleAwardSort,
|
||||||
} = useTableSort(filteredAwards, null, null, {
|
} = useTableSort(filteredAwards, null, null, {
|
||||||
referenceNo: (a) => a.referenceNo,
|
referenceNo: (a) => a.referenceNo,
|
||||||
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
|
organisationId: (a) => {
|
||||||
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
|
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,
|
amount: (a) => a.amount,
|
||||||
status: (a) => getApprovalStatusBadge(a.status).label,
|
status: (a) => getApprovalStatusBadge(a.status).label,
|
||||||
createdAt: (a) => a.createdAt,
|
createdAt: (a) => a.createdAt,
|
||||||
@@ -657,7 +663,10 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleBudgetSort,
|
handleSort: handleBudgetSort,
|
||||||
} = useTableSort(filteredBudgets, null, null, {
|
} = useTableSort(filteredBudgets, null, null, {
|
||||||
name: (b) => b.name,
|
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,
|
amount: (b) => b.amount,
|
||||||
createdAt: (b) => b.createdAt,
|
createdAt: (b) => b.createdAt,
|
||||||
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
|
||||||
@@ -671,8 +680,15 @@ export const GrantsPage: React.FC = () => {
|
|||||||
handleSort: handleDisbSort,
|
handleSort: handleDisbSort,
|
||||||
} = useTableSort(filteredDisbursements, null, null, {
|
} = useTableSort(filteredDisbursements, null, null, {
|
||||||
id: (d) => d.id,
|
id: (d) => d.id,
|
||||||
projectId: (d) => d.projectId,
|
projectId: (d) => {
|
||||||
milestoneId: (d) => d.milestoneId || 0,
|
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,
|
amount: (d) => d.amount,
|
||||||
paymentReference: (d) => d.paymentReference || '',
|
paymentReference: (d) => d.paymentReference || '',
|
||||||
status: (d) => getApprovalStatusBadge(d.status).label,
|
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>
|
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-slate-200 font-medium">
|
<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>
|
||||||
<td className="py-3.5 px-4 text-slate-400">
|
<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>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(awd.amount)}
|
{formatZAR(awd.amount)}
|
||||||
@@ -1014,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Budget Line Category
|
Budget Line Category
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
|
||||||
Approved Amount (ZAR)
|
Approved Amount (ZAR)
|
||||||
@@ -1053,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span className="font-semibold text-white block">{b.name}</span>
|
<span className="font-semibold text-white block">{b.name}</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
|
||||||
</td>
|
</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">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
{formatZAR(b.amount)}
|
{formatZAR(b.amount)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1184,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Disbursement ID
|
Disbursement ID
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Project ID
|
Project
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
Milestone Association
|
Milestone Association
|
||||||
@@ -1223,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||||||
DSB-{d.id}
|
DSB-{d.id}
|
||||||
</td>
|
</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">
|
<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>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
|
||||||
{formatZAR(d.amount)}
|
{formatZAR(d.amount)}
|
||||||
@@ -1564,6 +1632,13 @@ export const GrantsPage: React.FC = () => {
|
|||||||
toastError('Record Approved', 'Approved awards cannot be modified.');
|
toastError('Record Approved', 'Approved awards cannot be modified.');
|
||||||
return;
|
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({
|
updateAwardMutation.mutate({
|
||||||
awardId: editingAward.id,
|
awardId: editingAward.id,
|
||||||
organisationId: values.organisationId,
|
organisationId: values.organisationId,
|
||||||
@@ -1652,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
<option value={ApprovalStatus.Pending}>Pending Approval</option>
|
||||||
<option value={ApprovalStatus.Review}>Under Review</option>
|
<option value={ApprovalStatus.Review}>Under Review</option>
|
||||||
<option value={ApprovalStatus.Approved}>Approved & 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 & Activated {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -148,7 +148,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
} 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]);
|
}, [targetIdParam, folderMessages, activeFolder]);
|
||||||
|
|
||||||
@@ -665,6 +669,22 @@ export const InboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||||
</button>
|
</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">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
{formatDate(item.createdAt)}
|
{formatDate(item.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
recordIndicatorActual,
|
recordIndicatorActual,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
getActivities,
|
getActivities,
|
||||||
|
getDisbursements,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
queryFn: () => getBudgets(1, 100),
|
queryFn: () => getBudgets(1, 100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allDisbursements = [] } = useQuery({
|
||||||
|
queryKey: ['disbursements'],
|
||||||
|
queryFn: () => getDisbursements(1, 200),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [allBudgets]);
|
}, [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
|
// Filtered Projects
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projects.filter((p) => {
|
return projects.filter((p) => {
|
||||||
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
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: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
|
||||||
|
|
||||||
// Table Sorting
|
// Table Sorting
|
||||||
const customExtractors = {
|
const customExtractors = {
|
||||||
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
if (risk.isAtRisk) return 2;
|
if (risk.isAtRisk) return 2;
|
||||||
return 3;
|
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(
|
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||||||
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SortableTh>
|
</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">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<span>Project Variance</span>
|
<span>Project Variance</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Project Variance"
|
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"
|
size="sm"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
const pActs = allProjectActivities.get(p.id) || [];
|
const pActs = allProjectActivities.get(p.id) || [];
|
||||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
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;
|
const isPos = pVariance >= 0;
|
||||||
return (
|
return (
|
||||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
<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-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||||
: 'text-rose-400 bg-rose-500/10 border-rose-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))}`}
|
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
getOrganisations,
|
getOrganisations,
|
||||||
getAwards,
|
getAwards,
|
||||||
getContracts,
|
getContracts,
|
||||||
|
getProjects,
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
notifyInviteApproved,
|
notifyInviteApproved,
|
||||||
@@ -79,6 +80,8 @@ import {
|
|||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
|
ApprovalStatus,
|
||||||
|
ContractStatus,
|
||||||
User,
|
User,
|
||||||
Invite,
|
Invite,
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
@@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
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 canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||||
const canViewInvites = canView('settings_invites');
|
const canViewInvites = canView('settings_invites');
|
||||||
const canViewNotifications = canView('settings_notifications');
|
const canViewNotifications = canView('settings_notifications');
|
||||||
@@ -240,6 +243,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
|
||||||
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
|
||||||
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
const [inviteContractId, setInviteContractId] = useState<string | number>('');
|
||||||
|
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
|
||||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||||
const [inviteMessage, setInviteMessage] = useState('');
|
const [inviteMessage, setInviteMessage] = useState('');
|
||||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||||
@@ -252,6 +256,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
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}`;
|
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
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({
|
const { data: organisations = [] } = useQuery({
|
||||||
queryKey: ['organisations'],
|
queryKey: ['organisations'],
|
||||||
queryFn: () => getOrganisations(1, 50),
|
queryFn: () => getOrganisations(1, 50),
|
||||||
@@ -310,6 +292,104 @@ export const SettingsPage: React.FC = () => {
|
|||||||
queryFn: () => getContracts(1, 50),
|
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({
|
const sendInviteMutation = useMutation({
|
||||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
@@ -319,8 +399,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setInviteRecipientUuid('');
|
setInviteRecipientUuid('');
|
||||||
setInviteMessage('');
|
setInviteMessage('');
|
||||||
|
|
||||||
|
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||||
|
storeInvitePlatform(invId, variables.platform);
|
||||||
|
|
||||||
if (inviteAutoApprove) {
|
if (inviteAutoApprove) {
|
||||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
|
||||||
markInviteAsApprovedInStorage(invId, currentTenantId);
|
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||||
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||||
|
|
||||||
@@ -332,6 +414,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
organisationId: variables.organisationId,
|
organisationId: variables.organisationId,
|
||||||
awardId: variables.awardId,
|
awardId: variables.awardId,
|
||||||
contractId: variables.contractId,
|
contractId: variables.contractId,
|
||||||
|
projectId: variables.projectId,
|
||||||
message: variables.message,
|
message: variables.message,
|
||||||
},
|
},
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -417,6 +500,38 @@ export const SettingsPage: React.FC = () => {
|
|||||||
handleSort: handleNotifSort,
|
handleSort: handleNotifSort,
|
||||||
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
|
} = 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
|
// 4. Audit Log Ledger State
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -485,9 +600,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
header: 'Changes / Arguments',
|
header: 'Changes / Arguments',
|
||||||
key: 'changes',
|
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(
|
const uniqueAuditEntities = Array.from(
|
||||||
@@ -557,13 +685,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const inviteExportColumns: ExportColumn<Invite>[] = [
|
const inviteExportColumns: ExportColumn<Invite>[] = [
|
||||||
{ header: 'Recipient (UUID)', key: 'recipient' },
|
{
|
||||||
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
|
header: 'Recipient Email',
|
||||||
{ header: 'Invitation Message', key: 'message' },
|
key: 'recipient',
|
||||||
|
format: (val) => resolveUserEmail(val),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Channel / Platform',
|
||||||
|
key: 'platform',
|
||||||
|
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Target Organisation / Grant',
|
header: 'Target Organisation / Grant',
|
||||||
key: 'invitedOrganisationId',
|
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) : '—') },
|
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
|
||||||
];
|
];
|
||||||
@@ -589,7 +729,15 @@ export const SettingsPage: React.FC = () => {
|
|||||||
sortColumn: inviteSortCol,
|
sortColumn: inviteSortCol,
|
||||||
sortDirection: inviteSortDir,
|
sortDirection: inviteSortDir,
|
||||||
handleSort: handleInviteSort,
|
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 getActionBadge = (action?: string | null) => {
|
||||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||||
@@ -1180,14 +1328,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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">
|
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||||
Recipient (UUID)
|
Recipient Email
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Channel
|
Channel
|
||||||
</SortableTh>
|
</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">
|
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Target Org / Grant
|
Target Org / Grant
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
@@ -1205,13 +1350,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingInvites ? (
|
{loadingInvites ? (
|
||||||
<tr>
|
<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...
|
Loading invitations ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedInvites.length === 0 ? (
|
) : sortedInvites.length === 0 ? (
|
||||||
<tr>
|
<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 “Dispatch Invitation” to send one.
|
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1221,22 +1366,34 @@ export const SettingsPage: React.FC = () => {
|
|||||||
approvedInviteIds.includes(String(inv.id)) ||
|
approvedInviteIds.includes(String(inv.id)) ||
|
||||||
approvedInviteIds.includes(`inv_${idx}`) ||
|
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||||
approvedInviteIds.includes(String(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 (
|
return (
|
||||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
||||||
{inv.recipient}
|
<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>
|
||||||
<td className="py-3.5 px-4">
|
<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">
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
<td className="py-3.5 px-4 text-slate-300 text-xs">
|
||||||
{inv.message}
|
<div className="font-medium text-white">{orgName}</div>
|
||||||
</td>
|
<div className="text-slate-400 text-[11px] mt-0.5">
|
||||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
Award #{inv.awardId || '—'}
|
||||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
{isApproved ? (
|
{isApproved ? (
|
||||||
@@ -1260,7 +1417,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
Partner Notified
|
Partner Notified
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : canApproveRecord('settings_invites', inv.createdBy) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleApproveInvite(inv, idx)}
|
onClick={() => handleApproveInvite(inv, idx)}
|
||||||
@@ -1270,6 +1427,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2490,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!inviteAwardId) {
|
||||||
|
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!inviteRecipientUuid || !inviteMessage) {
|
if (!inviteRecipientUuid || !inviteMessage) {
|
||||||
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
|
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendInviteMutation.mutate({
|
sendInviteMutation.mutate({
|
||||||
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
|
||||||
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
|
||||||
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
|
awardId: inviteAwardId,
|
||||||
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
|
contractId: inviteContractId,
|
||||||
|
projectId: inviteProjectId || 0,
|
||||||
recipient: inviteRecipientUuid,
|
recipient: inviteRecipientUuid,
|
||||||
message: inviteMessage,
|
message: inviteMessage,
|
||||||
platform: invitePlatform,
|
platform: invitePlatform,
|
||||||
@@ -2506,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div>
|
{eligibleAwards.length === 0 ? (
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<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">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
|
||||||
Recipient User UUID *
|
<div>
|
||||||
</label>
|
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
|
||||||
<HelpTooltip
|
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
|
||||||
title="Recipient UUID"
|
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 & Active</strong>.
|
||||||
content="Unique directory user identifier or email for targeted delivery."
|
</span>
|
||||||
size="sm"
|
</div>
|
||||||
placement="right"
|
|
||||||
/>
|
|
||||||
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteRecipientUuid}
|
value={inviteRecipientUuid}
|
||||||
@@ -2528,6 +2734,98 @@ 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">
|
||||||
|
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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
@@ -2557,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
Originating Organisation
|
Originating Authority
|
||||||
</label>
|
</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Inviting Authority"
|
title="Inviting Authority"
|
||||||
@@ -2580,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300">
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
@@ -2680,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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'}
|
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
|
||||||
|
|||||||
@@ -1514,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
|||||||
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||||
awardId: toSafeInt64String(payload.awardId),
|
awardId: toSafeInt64String(payload.awardId),
|
||||||
contractId: toSafeInt64String(payload.contractId),
|
contractId: toSafeInt64String(payload.contractId),
|
||||||
|
projectId: toSafeInt64String(payload.projectId || 0),
|
||||||
recipient: payload.recipient,
|
recipient: payload.recipient,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||||
@@ -1601,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
|||||||
subject: payload.subject || null,
|
subject: payload.subject || null,
|
||||||
destination: payload.destination || 'In-App User Inbox',
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
message: payload.message,
|
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
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getTenants = async (): Promise<Tenant[]> => {
|
export const getTenants = async (): Promise<Tenant[]> => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
|
|||||||
|
|
||||||
export interface UpdateContractRequest {
|
export interface UpdateContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
status: ContractStatus;
|
status: ContractStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecuteContractRequest {
|
export interface ExecuteContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
externalSignatureId: string;
|
externalSignatureId: string;
|
||||||
signedDocumentUrl: string;
|
signedDocumentUrl: string;
|
||||||
}
|
}
|
||||||
@@ -838,6 +841,7 @@ export interface Invite {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy?: string | null;
|
updatedBy?: string | null;
|
||||||
@@ -852,6 +856,7 @@ export interface InviteRequest {
|
|||||||
invitedOrganisationId: number | string;
|
invitedOrganisationId: number | string;
|
||||||
awardId: number | string;
|
awardId: number | string;
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
|
projectId?: number | string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
message: string;
|
message: string;
|
||||||
platform: NotificationPlatform;
|
platform: NotificationPlatform;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = rawVal ?? '';
|
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;
|
rowObj[col.header] = formatted;
|
||||||
});
|
});
|
||||||
return rowObj;
|
return rowObj;
|
||||||
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
|
|||||||
} else {
|
} else {
|
||||||
formatted = String(rawVal ?? '');
|
formatted = String(rawVal ?? '');
|
||||||
}
|
}
|
||||||
|
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
return Math.max(max, formatted.length);
|
return Math.max(max, formatted.length);
|
||||||
}, headerLen);
|
}, headerLen);
|
||||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
|
||||||
});
|
});
|
||||||
worksheet['!cols'] = colWidths;
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NotificationPlatform : int
|
public enum NotificationPlatform : int
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
|
Trash2,
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -197,6 +198,7 @@ export function DashboardPage() {
|
|||||||
top5Unread,
|
top5Unread,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
|
deleteMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
isSending,
|
isSending,
|
||||||
} = useInAppNotifications();
|
} = useInAppNotifications();
|
||||||
@@ -1221,9 +1223,25 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400/70 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
{formatFullDate(msg.createdAt)}
|
<span className="text-xs text-slate-400/70">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1905,16 +1923,30 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 justify-between pt-2 border-t border-white/[0.08]">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleReplyToMessage(selectedNotification)}
|
type="button"
|
||||||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
|
onClick={() => handleReplyToMessage(selectedNotification)}
|
||||||
>
|
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
|
||||||
<Reply className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Reply</span>
|
<Reply className="w-3.5 h-3.5" />
|
||||||
</button>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedNotification(null)}
|
onClick={() => setSelectedNotification(null)}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
getUserNotifications,
|
getUserNotifications,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
createNotification,
|
createNotification,
|
||||||
|
updateNotification,
|
||||||
|
archiveNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
resolveUserIdByEmail,
|
resolveUserIdByEmail,
|
||||||
@@ -124,7 +126,10 @@ export function useInAppNotifications() {
|
|||||||
|
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
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]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
@@ -151,7 +156,9 @@ export function useInAppNotifications() {
|
|||||||
}, [inAppNotifications, starredIds, trashedIds]);
|
}, [inAppNotifications, starredIds, trashedIds]);
|
||||||
|
|
||||||
const trashedNotifications = useMemo(() => {
|
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]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
@@ -191,7 +198,8 @@ export function useInAppNotifications() {
|
|||||||
});
|
});
|
||||||
}, [starredStorageKey]);
|
}, [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);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||||
@@ -201,9 +209,22 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
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);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => {
|
setTrashedIds(prev => {
|
||||||
const next = prev.filter(x => x !== strId);
|
const next = prev.filter(x => x !== strId);
|
||||||
@@ -213,7 +234,19 @@ export function useInAppNotifications() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
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 permanentDelete = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
|||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
|
{ value: NotificationStatus.Archived, label: 'Archived' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -536,6 +537,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case NotificationStatus.Archived:
|
||||||
|
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
|
||||||
case NotificationStatus.Draft:
|
case NotificationStatus.Draft:
|
||||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||||
case NotificationStatus.Delivered:
|
case NotificationStatus.Delivered:
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ import {
|
|||||||
UpdateRuleRequest,
|
UpdateRuleRequest,
|
||||||
Pagination,
|
Pagination,
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationPlatform,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
Role,
|
Role,
|
||||||
Claim,
|
Claim,
|
||||||
@@ -1898,7 +1899,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
|
|||||||
subject: payload.subject || null,
|
subject: payload.subject || null,
|
||||||
destination: payload.destination || 'In-App User Inbox',
|
destination: payload.destination || 'In-App User Inbox',
|
||||||
message: payload.message,
|
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
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getTenants = async (): Promise<Tenant[]> => {
|
export const getTenants = async (): Promise<Tenant[]> => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum NotificationStatus {
|
|||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
Draft = 10,
|
Draft = 10,
|
||||||
|
Archived = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
|
|||||||
Reference in New Issue
Block a user