Implemented notification strategy
This commit is contained in:
@@ -60,6 +60,7 @@ import {
|
||||
assignUserRole,
|
||||
getSystemRoles,
|
||||
getCurrentUserTenantId,
|
||||
notifyNewUserCreated,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -251,7 +252,7 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const assignableRolesForOrg = React.useMemo(() => {
|
||||
return systemRoles.filter(
|
||||
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
|
||||
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
|
||||
);
|
||||
}, [systemRoles]);
|
||||
|
||||
@@ -274,9 +275,9 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id);
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id);
|
||||
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
|
||||
}
|
||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||
|
||||
@@ -334,6 +335,19 @@ export const GovernancePage: React.FC = () => {
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
}
|
||||
|
||||
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
|
||||
if (createdUser) {
|
||||
await notifyNewUserCreated({
|
||||
userId: createdUser.id,
|
||||
email: quickUserEmail,
|
||||
isPartner: false,
|
||||
roleName: 'Executive',
|
||||
tenantId,
|
||||
}).catch(console.warn);
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||
setQuickUserEmail('');
|
||||
setQuickUserName('');
|
||||
@@ -644,7 +658,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -861,7 +875,7 @@ export const GovernancePage: React.FC = () => {
|
||||
key: 'portfolioId',
|
||||
format: (val) => {
|
||||
const port = portfolios.find((p) => String(p.id) === String(val));
|
||||
return port ? port.name : `Portfolio #${val}`;
|
||||
return port && port.name ? port.name : `Portfolio #${val}`;
|
||||
},
|
||||
},
|
||||
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||
@@ -1253,8 +1267,8 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
||||
{org ? org.name : `Org #${p.organisationId}`}
|
||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
|
||||
{org && org.name ? org.name : `Org #${p.organisationId}`}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,13 @@ import {
|
||||
getContracts,
|
||||
getAuditLogs,
|
||||
getNotifications,
|
||||
notifyInviteApproved,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
getApprovedInviteIds,
|
||||
markInviteAsApprovedInStorage,
|
||||
} from '../services/notificationRulesService';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
@@ -238,6 +244,31 @@ export const SettingsPage: React.FC = () => {
|
||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||
const [inviteMessage, setInviteMessage] = useState('');
|
||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
|
||||
|
||||
// Approved invites tracking state
|
||||
const currentTenantId = getCurrentUserTenantId();
|
||||
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
|
||||
getApprovedInviteIds(currentTenantId)
|
||||
);
|
||||
|
||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||
|
||||
await notifyInviteApproved({
|
||||
invite: inv,
|
||||
}).catch(console.warn);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
success(
|
||||
'Invitation Approved',
|
||||
'Partner user has been notified via In-App and Email messages with link to Partner Portal (https://partner.ifri.khongisa.co.za/).'
|
||||
);
|
||||
};
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
@@ -282,12 +313,33 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
const sendInviteMutation = useMutation({
|
||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, variables) => {
|
||||
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||
setIsSendInviteModalOpen(false);
|
||||
setInviteRecipientUuid('');
|
||||
setInviteMessage('');
|
||||
|
||||
if (inviteAutoApprove) {
|
||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||
markInviteAsApprovedInStorage(invId, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
|
||||
|
||||
notifyInviteApproved({
|
||||
invite: {
|
||||
id: invId,
|
||||
recipient: variables.recipient,
|
||||
invitedOrganisationId: variables.invitedOrganisationId,
|
||||
organisationId: variables.organisationId,
|
||||
awardId: variables.awardId,
|
||||
contractId: variables.contractId,
|
||||
message: variables.message,
|
||||
},
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}).catch(console.warn);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Dispatch Error', err);
|
||||
@@ -1125,7 +1177,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto min-w-0 w-full">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[950px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||
@@ -1140,46 +1192,90 @@ export const SettingsPage: React.FC = () => {
|
||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||
Target Org / Grant
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
||||
Created Date
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingInvites ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading invitations ledger...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedInvites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedInvites.map((inv, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||
{inv.recipient}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{getNotificationPlatformLabel(inv.platform)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
||||
{inv.message}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
||||
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
sortedInvites.map((inv, idx) => {
|
||||
const isApproved =
|
||||
approvedInviteIds.includes(String(inv.id)) ||
|
||||
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||
approvedInviteIds.includes(String(idx));
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||
{inv.recipient}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{getNotificationPlatformLabel(inv.platform)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
|
||||
{inv.message}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Approved
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Pending Approval
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
||||
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
{isApproved ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400/90 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Partner Notified
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveInvite(inv, idx)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border border-emerald-500/30 transition shadow-xs cursor-pointer"
|
||||
title="Approve this invitation and issue InApp and Email notifications to the partner user"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -2559,6 +2655,22 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-950/20 border border-blue-900/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inviteAutoApprove"
|
||||
checked={inviteAutoApprove}
|
||||
onChange={(e) => setInviteAutoApprove(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="inviteAutoApprove" className="text-xs text-slate-300 cursor-pointer select-none">
|
||||
<span className="font-semibold text-white block">Formally Approve Invitation & Notify Partner</span>
|
||||
<span className="text-[11px] text-slate-400 leading-relaxed block mt-0.5">
|
||||
Automatically marks invite as Approved and issues In-App and Email notifications directing the partner user to the Partner Portal (<strong className="text-blue-400 font-mono">https://partner.ifri.khongisa.co.za/</strong>).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user