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