Refactored UI

Added Role filed to user table and endpoint implementation
This commit is contained in:
2026-09-28 10:25:00 +02:00
parent 7037ea72ca
commit ea71e55e2d
21 changed files with 3100 additions and 128 deletions
@@ -40,6 +40,7 @@ import { toolsService } from '../../services/toolsService';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
@@ -437,9 +438,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Total Contracts
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Total Contracts
</span>
<HelpTooltip
title="Total Legal Contracts"
content="Total grant agreements and legal instruments registered across portfolios."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center border border-blue-500/20">
<FileText className="w-4 h-4" />
</div>
@@ -454,9 +462,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Active Value (ZAR)
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Active Value (ZAR)
</span>
<HelpTooltip
title="Active Contract Value"
content="Cumulative financial value of legally active commitments."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center border border-emerald-500/20">
<ShieldCheck className="w-4 h-4" />
</div>
@@ -471,9 +486,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
In Review / Draft
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
In Review / Draft
</span>
<HelpTooltip
title="Draft & Review Contracts"
content="Contracts currently undergoing legal review, signing, or counter-signature."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center border border-amber-500/20">
<Clock className="w-4 h-4" />
</div>
@@ -488,9 +510,16 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Approved Awards Available
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Approved Awards Available
</span>
<HelpTooltip
title="Approved Awards"
content="Approved grant awards eligible for formal contract generation and document attachment."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center border border-indigo-500/20">
<Briefcase className="w-4 h-4" />
</div>
@@ -54,6 +54,7 @@ import {
} from '../../lib/formatters';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
@@ -419,10 +420,18 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
{filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view
</span>
</div>
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" />
Evidence Review & Verification Hub
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" />
Evidence Review & Verification Hub
</h3>
<HelpTooltip
title="Deliverable Evidence Verification"
content="Inspect and verify deliverable evidence uploaded against project milestones and operational activities before authorizing completion sign-offs."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs text-slate-400 mt-1 max-w-3xl leading-relaxed">
Inspect and verify deliverable evidence uploaded by awardees against project milestones and operational activities.
Review supporting documentation, download verified files, and authorize completion sign-offs.
@@ -83,6 +83,7 @@ export const BeneficiariesPage: React.FC = () => {
const [isCompModalOpen, setIsCompModalOpen] = useState(false);
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
const [compTargetProjectId, setCompTargetProjectId] = useState<string>('');
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
@@ -109,16 +110,44 @@ export const BeneficiariesPage: React.FC = () => {
enabled: !!effectiveProjectId,
});
// Query compliance items
// Refactored compliance items data retrieval:
// Fetches from api/projects/{projectId}/complianceitems for the selected project,
// or across all projects in the portfolio when 'all' is chosen.
const {
data: complianceItems = [],
isLoading: loadingComps,
isError: isCompsError,
refetch: refetchComps,
isFetching: fetchingComps,
} = useQuery({
queryKey: ['complianceItems', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
queryKey: ['complianceItems', selectedProjectId, projects.map((p) => p.id).join(',')],
queryFn: async (): Promise<ComplianceItem[]> => {
if (selectedProjectId !== 'all') {
const items = await getProjectComplianceItems(selectedProjectId);
return (items || []).map((ci) => ({
...ci,
projectId: ci.projectId || selectedProjectId,
}));
}
if (!projects || projects.length === 0) return [];
const settled = await Promise.allSettled(
projects.map(async (p) => {
const items = await getProjectComplianceItems(p.id);
return (items || []).map((ci) => ({
...ci,
projectId: ci.projectId || p.id,
}));
})
);
const combined: ComplianceItem[] = [];
settled.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
combined.push(...res.value);
}
});
return combined;
},
enabled: projects.length > 0,
});
// Query outcomes for selected beneficiary
@@ -190,8 +219,9 @@ export const BeneficiariesPage: React.FC = () => {
const createCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements });
const targetProjId = compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id);
if (!targetProjId) throw new Error('No project selected');
return createComplianceItem({ projectId: targetProjId, title: vals.title, requirements: vals.requirements });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
@@ -285,6 +315,11 @@ export const BeneficiariesPage: React.FC = () => {
];
const compExportColumns: ExportColumn<ComplianceItem>[] = [
{
header: 'Project',
key: 'projectId',
format: (val) => projects.find((p) => String(p.id) === String(val))?.name || 'Project',
},
{ header: 'Compliance Requirement', key: 'title' },
{ header: 'Requirements Detail', key: 'requirements' },
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
@@ -347,10 +382,18 @@ export const BeneficiariesPage: React.FC = () => {
<Users className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Beneficiaries & Compliance Register
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Beneficiaries & Compliance Register
</h1>
<HelpTooltip
title="Beneficiary & Compliance Framework"
content="Record target population demographics, track real-world participant reach vs targets, and monitor statutory compliance records across projects."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
</p>
</div>
@@ -362,7 +405,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
<HelpTooltip
title="Beneficiary Cohorts"
content="Registered demographic stakeholder groups receiving direct programmatic support."
size="sm"
/>
</div>
<Users className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p>
@@ -371,7 +421,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
<HelpTooltip
title="Target Reach"
content="Total planned participant headcount agreed upon across project targets."
size="sm"
/>
</div>
<Target className="w-4 h-4 text-indigo-400" />
</div>
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p>
@@ -380,7 +437,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
<HelpTooltip
title="Actual Reached Headcount"
content="Cumulative verified individual or entity participants supported to date."
size="sm"
/>
</div>
<Award className="w-4 h-4 text-emerald-400" />
</div>
<div className="flex items-baseline gap-2">
@@ -394,7 +458,14 @@ export const BeneficiariesPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
<HelpTooltip
title="Compliance Items"
content="Statutory, environmental, labor, and financial audit verification requirements."
size="sm"
/>
</div>
<ShieldCheck className="w-4 h-4 text-cyan-400" />
</div>
<div className="flex items-baseline gap-2">
@@ -476,6 +547,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
>
<option value="all">All Projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -660,6 +732,7 @@ export const BeneficiariesPage: React.FC = () => {
onChange={(e) => setSelectedProjectId(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
>
<option value="all">All Projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -695,6 +768,7 @@ export const BeneficiariesPage: React.FC = () => {
<button
onClick={() => {
setEditingComp(null);
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
setIsCompModalOpen(true);
}}
@@ -714,6 +788,11 @@ export const BeneficiariesPage: React.FC = () => {
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
Compliance Title
</SortableTh>
{selectedProjectId === 'all' && (
<SortableTh column="projectId" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Project
</SortableTh>
)}
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Requirements
</SortableTh>
@@ -727,9 +806,25 @@ export const BeneficiariesPage: React.FC = () => {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingComps ? (
{isCompsError ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-rose-400">
<div className="flex flex-col items-center gap-2">
<AlertCircle className="w-6 h-6 text-rose-400" />
<span>Unable to load compliance items for this project from API.</span>
<button
type="button"
onClick={() => refetchComps()}
className="mt-2 px-3 py-1 bg-slate-800 text-white rounded-lg border border-slate-700 text-xs hover:bg-slate-700 transition cursor-pointer"
>
Retry
</button>
</div>
</td>
</tr>
) : loadingComps ? (
<tr>
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-slate-400">
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading compliance items...</span>
@@ -738,8 +833,10 @@ export const BeneficiariesPage: React.FC = () => {
</tr>
) : sortedComps.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No compliance items recorded for this project.
<td colSpan={selectedProjectId === 'all' ? 6 : 5} className="py-12 text-center text-slate-400">
{selectedProjectId === 'all'
? 'No compliance items recorded across all projects.'
: 'No compliance items recorded for this project.'}
</td>
</tr>
) : (
@@ -751,6 +848,13 @@ export const BeneficiariesPage: React.FC = () => {
<span>{c.title}</span>
</div>
</td>
{selectedProjectId === 'all' && (
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300 font-medium">
<span className="px-2 py-0.5 rounded-md bg-slate-800/90 text-slate-200 border border-slate-700/60 text-[11px]">
{projects.find((p) => String(p.id) === String(c.projectId))?.name || 'Project'}
</span>
</td>
)}
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'}
</td>
@@ -869,6 +973,23 @@ export const BeneficiariesPage: React.FC = () => {
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
className="space-y-4"
>
{!editingComp && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Project *</label>
<select
value={compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '')}
onChange={(e) => setCompTargetProjectId(e.target.value)}
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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
<input
@@ -35,6 +35,7 @@ import {
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -522,10 +523,18 @@ export const ChangeManagementPage: React.FC = () => {
<GitPullRequest className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Change Management & Issue Resolution
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Change Management & Issue Resolution
</h1>
<HelpTooltip
title="Project Change & Issue Control"
content="Track project impediments, review contractual variations, and resolve change requests in-place through governance workflows."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Track project execution impediments, review contractual variations, and resolve change requests in-place.
</p>
</div>
@@ -537,7 +546,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Active Issues</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Active Issues</span>
<HelpTooltip
title="Active Project Issues"
content="Open project roadblocks, risks, or performance impediments requiring active mitigation."
size="sm"
/>
</div>
<AlertOctagon className="w-4 h-4 text-rose-400" />
</div>
<p className="text-2xl font-bold text-rose-400">{issues.filter((i) => i.status !== ActivityStatus.Inactive).length}</p>
@@ -546,7 +562,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Resolved Issues</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Resolved Issues</span>
<HelpTooltip
title="Resolved Issues"
content="Impediments that have been remediated and formally closed with documented resolution notes."
size="sm"
/>
</div>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-emerald-400">
@@ -557,7 +580,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Pending Changes</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Pending Changes</span>
<HelpTooltip
title="Pending Variations"
content="Formal change requests awaiting governance review, budget validation, and sign-off."
size="sm"
/>
</div>
<GitPullRequest className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
@@ -568,7 +598,14 @@ export const ChangeManagementPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Variations</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Variations</span>
<HelpTooltip
title="Approved Variations"
content="Contract amendments and scope adjustments approved by the governing committee."
size="sm"
/>
</div>
<Coins className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Shield,
@@ -36,6 +36,7 @@ import {
ChevronDown,
ChevronRight,
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import {
getUsers,
@@ -114,7 +115,12 @@ export const SettingsPage: React.FC = () => {
staleTime: 5 * 60 * 1000,
});
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
const {
data: usersList = [],
isLoading: loadingUsers,
isFetching: isFetchingUsers,
refetch: refetchUsers,
} = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
@@ -191,11 +197,32 @@ export const SettingsPage: React.FC = () => {
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const { data: invitesList = [], isLoading: loadingInvites } = useQuery({
// 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),
@@ -432,6 +459,25 @@ export const SettingsPage: React.FC = () => {
handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
const userExportColumns: ExportColumn<User>[] = [
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
{ header: 'User Identifier', key: 'id' },
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
];
const inviteExportColumns: ExportColumn<Invite>[] = [
{ header: 'Recipient (UUID)', key: 'recipient' },
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
{ header: 'Invitation Message', key: 'message' },
{
header: 'Target Organisation / Grant',
key: 'invitedOrganisationId',
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
},
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
];
const {
sortedData: sortedUsers,
sortColumn: userSortCol,
@@ -444,7 +490,7 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -621,44 +667,101 @@ export const SettingsPage: React.FC = () => {
{/* ============================================================== */}
{activeTab === 'users' && (
<div className="space-y-4">
{/* Filter and Search Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
{/* Standardized Toolbar & Filter Bar directly above Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm:p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
{/* Filters on the left */}
<div className="flex items-center gap-2.5 flex-wrap flex-1">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
</div>
{(userEmailSearch.trim() !== '' || userStatusFilter !== 'all') && (
<button
type="button"
onClick={() => {
setUserEmailSearch('');
setUserStatusFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span>
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
{/* Actions on the right: Refresh, Export, Sync */}
<div className="flex items-center gap-2 flex-wrap justify-end">
<button
type="button"
onClick={() => {
refetchUsers();
success('Refreshed', 'User directory updated from API.');
}}
disabled={isFetchingUsers}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh user directory from API"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingUsers ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="User Directory &amp; Access Register"
subtitle="Canonical directory of authorized users and account states"
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
columns={userExportColumns}
data={sortedUsers}
/>
<button
onClick={() => refetchUsers()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
title="Refresh user list"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer disabled:opacity-50"
title="Synchronize user profile from JWT session and directory"
>
<RefreshCw className="w-4 h-4" />
<UserCheck className="w-3.5 h-3.5" />
<span>{syncMutation.isPending ? 'Syncing...' : 'Sync Directory'}</span>
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Authorized User Directory ({sortedUsers.length} of {usersList.length})
</h3>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="right"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
@@ -831,13 +934,100 @@ export const SettingsPage: React.FC = () => {
)}
</div>
{/* Standardized Toolbar & Filter Bar directly above Sent Invitations Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-3.5 sm:p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
{/* Filters on the left */}
<div className="flex items-center gap-2.5 flex-wrap flex-1">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-2.5" />
<input
type="text"
value={inviteSearchTerm}
onChange={(e) => setInviteSearchTerm(e.target.value)}
placeholder="Search recipient UUID, message..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-900 border border-slate-800 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 shadow-xs font-mono"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<select
value={invitePlatformFilter}
onChange={(e) => setInvitePlatformFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Channels</option>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{(inviteSearchTerm.trim() !== '' || invitePlatformFilter !== 'all') && (
<button
type="button"
onClick={() => {
setInviteSearchTerm('');
setInvitePlatformFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
{/* Actions on the right: Refresh, Export, Dispatch */}
<div className="flex items-center gap-2 flex-wrap justify-end">
<button
type="button"
onClick={() => {
refetchInvites();
success('Refreshed', 'Sent invitations ledger updated from API.');
}}
disabled={isFetchingInvites}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh invitations from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingInvites ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Sent Communications &amp; Invitations Ledger"
subtitle="Telemetry records of candidate invitations and statutory communications dispatched"
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
columns={inviteExportColumns}
data={sortedInvites}
/>
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Dispatch Invitation</span>
</button>
</div>
</div>
{/* Sent Invites Ledger */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/40 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider">
Sent Invitations Ledger
</span>
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span>
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Sent Invitations Ledger ({sortedInvites.length} of {invitesList.length})
</h3>
<HelpTooltip
title="Sent Invitations"
content="Historical record of dispatched applicant invites, platform channels, and associated awards."
size="sm"
placement="right"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
@@ -29,6 +29,7 @@ import {
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
@@ -195,10 +196,18 @@ export const SiteVisitsPage: React.FC = () => {
<MapPin className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Site Visits & Field Verification
</h1>
<p className="text-xs sm:text-sm text-slate-400">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Site Visits & Field Verification
</h1>
<HelpTooltip
title="Field Inspection & Verification"
content="Log physical site inspection reports, track verified evidence, document findings, and record governance approvals."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Log physical inspection reports, record field findings, and capture project feedback.
</p>
</div>
@@ -210,7 +219,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Total Site Visits</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Total Site Visits</span>
<HelpTooltip
title="Total Site Visits"
content="Total number of physical on-site inspection visits recorded across all projects."
size="sm"
/>
</div>
<MapPin className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-white">{siteVisits.length}</p>
@@ -219,7 +235,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Reports</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Approved Reports</span>
<HelpTooltip
title="Approved Inspection Reports"
content="Field inspection reports formally approved and verified by project governance."
size="sm"
/>
</div>
<CheckCircle2 className="w-4 h-4 text-teal-400" />
</div>
<p className="text-2xl font-bold text-teal-400">
@@ -230,7 +253,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Under Review</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Under Review</span>
<HelpTooltip
title="Pending Sign-Off"
content="Inspection findings and reports currently awaiting governance evaluation."
size="sm"
/>
</div>
<FileText className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
@@ -241,7 +271,14 @@ export const SiteVisitsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs font-semibold uppercase tracking-wider">Audited Projects</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider">Audited Projects</span>
<HelpTooltip
title="Audited Projects"
content="Projects with registered physical site monitoring records and active inspection history."
size="sm"
/>
</div>
<Building className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">{projects.length}</p>
@@ -1688,8 +1688,33 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
// ==========================================
export const getProjectComplianceItems = async (projectId: string | number): Promise<ComplianceItem[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/complianceitems/${hashedProjectId}`);
return res.data || [];
try {
// Primary updated route: /v1/api/projects/{projectId}/complianceitems
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${hashedProjectId}/complianceitems`);
return res.data || [];
} catch (err: any) {
if (err?.response?.status === 404 || err?.response?.status === 400) {
try {
// Fallback without /v1 prefix: /api/projects/{projectId}/complianceitems
const fallbackRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${hashedProjectId}/complianceitems`);
return fallbackRes.data || [];
} catch {
try {
// Unencoded project ID fallbacks
const rawIdRes = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${projectId}/complianceitems`);
return rawIdRes.data || [];
} catch {
try {
const rawApiRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${projectId}/complianceitems`);
return rawApiRes.data || [];
} catch {
throw err;
}
}
}
}
throw err;
}
};
export const getComplianceItem = async (complianceItemId: string | number): Promise<ComplianceItem> => {