Added, beneficiaries, site visits and change management

This commit is contained in:
2026-09-27 11:40:28 +02:00
parent fb27cc8c92
commit 36e5d3d7c8
16 changed files with 3132 additions and 212 deletions
@@ -0,0 +1,928 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Users,
Plus,
ShieldCheck,
CheckCircle2,
AlertCircle,
RefreshCw,
Search,
Filter,
CheckSquare,
Edit2,
FolderKanban,
Target,
Award,
Sparkles,
MapPin,
FileCheck,
} from 'lucide-react';
import {
getProjects,
getProjectBeneficiaries,
createBeneficiary,
updateBeneficiary,
getBeneficiaryOutcomes,
createOutcome,
getProjectComplianceItems,
createComplianceItem,
updateComplianceItem,
} 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';
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
const beneficiarySchema = z.object({
name: z.string().min(2, 'Beneficiary name required'),
category: z.string().min(2, 'Category required'),
location: z.string().min(2, 'Location required'),
targetCount: z.coerce.number().min(1, 'Target count must be at least 1'),
reachedCount: z.coerce.number().min(0, 'Reached count must be 0 or more'),
});
const complianceSchema = z.object({
title: z.string().min(3, 'Compliance title required'),
requirements: z.string().min(5, 'Requirements detail required'),
status: z.coerce.number().optional(),
});
const outcomeSchema = z.object({
name: z.string().min(3, 'Outcome name required'),
description: z.string().min(5, 'Description required'),
});
type BeneficiaryFormValues = z.infer<typeof beneficiarySchema>;
type ComplianceFormValues = z.infer<typeof complianceSchema>;
type OutcomeFormValues = z.infer<typeof outcomeSchema>;
export const BeneficiariesPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
const [searchTerm, setSearchTerm] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
// Modal states
const [isBenModalOpen, setIsBenModalOpen] = useState(false);
const [editingBen, setEditingBen] = useState<Beneficiary | null>(null);
const [isCompModalOpen, setIsCompModalOpen] = useState(false);
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [isOutcomeModalOpen, setIsOutcomeModalOpen] = useState(false);
// Queries
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
// Query beneficiaries
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
const {
data: beneficiaries = [],
isLoading: loadingBens,
refetch: refetchBens,
isFetching: fetchingBens,
} = useQuery({
queryKey: ['beneficiaries', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectBeneficiaries(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
});
// Query compliance items
const {
data: complianceItems = [],
isLoading: loadingComps,
refetch: refetchComps,
isFetching: fetchingComps,
} = useQuery({
queryKey: ['complianceItems', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
});
// Query outcomes for selected beneficiary
const { data: outcomes = [], refetch: refetchOutcomes } = useQuery({
queryKey: ['outcomes', selectedBenForDetail?.id],
queryFn: () => (selectedBenForDetail?.id ? getBeneficiaryOutcomes(selectedBenForDetail.id) : Promise.resolve([])),
enabled: !!selectedBenForDetail?.id,
});
// Forms
const benForm = useForm<BeneficiaryFormValues>({
resolver: zodResolver(beneficiarySchema),
defaultValues: { name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 },
});
const compForm = useForm<ComplianceFormValues>({
resolver: zodResolver(complianceSchema),
defaultValues: { title: '', requirements: '', status: ApprovalStatus.Pending },
});
const outcomeForm = useForm<OutcomeFormValues>({
resolver: zodResolver(outcomeSchema),
defaultValues: { name: '', description: '' },
});
// Mutations
const createBenMutation = useMutation({
mutationFn: (vals: BeneficiaryFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createBeneficiary({ ...vals, projectId: effectiveProjectId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
setIsBenModalOpen(false);
benForm.reset();
success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
},
onError: (err: any) => toastError('Registration Failed', err.message || 'Unable to save beneficiary.'),
});
const updateBenMutation = useMutation({
mutationFn: (vals: BeneficiaryFormValues) => {
if (!editingBen) throw new Error('No beneficiary selected');
return updateBeneficiary({ ...vals, beneficiaryId: editingBen.id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
setIsBenModalOpen(false);
setEditingBen(null);
benForm.reset();
success('Beneficiary Updated', 'Beneficiary details and targets updated.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update beneficiary.'),
});
const createCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
setIsCompModalOpen(false);
compForm.reset();
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
},
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to record compliance item.'),
});
const updateCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!editingComp) throw new Error('No item selected');
return updateComplianceItem({
complianceItemId: editingComp.id,
title: vals.title,
requirements: vals.requirements,
status: vals.status ?? ApprovalStatus.Pending,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
setIsCompModalOpen(false);
setEditingComp(null);
compForm.reset();
success('Compliance Item Updated', 'Status and requirement record updated.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update compliance item.'),
});
const createOutcomeMutation = useMutation({
mutationFn: (vals: OutcomeFormValues) => {
if (!selectedBenForDetail) throw new Error('No beneficiary selected');
return createOutcome({ beneficiaryId: selectedBenForDetail.id, name: vals.name, description: vals.description });
},
onSuccess: () => {
refetchOutcomes();
setIsOutcomeModalOpen(false);
outcomeForm.reset();
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
},
onError: (err: any) => toastError('Failed to Record Outcome', err.message || 'Unable to save outcome.'),
});
// Filtered & Sorted Beneficiaries
const filteredBens = useMemo(() => {
return beneficiaries.filter((b) => {
const matchesSearch =
!searchTerm.trim() ||
(b.name && b.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
(b.location && b.location.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCat = categoryFilter === 'all' || b.category === categoryFilter;
return matchesSearch && matchesCat;
});
}, [beneficiaries, searchTerm, categoryFilter]);
const { sortedData: sortedBens, sortColumn: benSortCol, sortDirection: benSortDir, handleSort: handleBenSort } =
useTableSort(filteredBens);
// Filtered & Sorted Compliance Items
const filteredComps = useMemo(() => {
return complianceItems.filter((c) => {
return (
!searchTerm.trim() ||
(c.title && c.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
(c.requirements && c.requirements.toLowerCase().includes(searchTerm.toLowerCase()))
);
});
}, [complianceItems, searchTerm]);
const { sortedData: sortedComps, sortColumn: compSortCol, sortDirection: compSortDir, handleSort: handleCompSort } =
useTableSort(filteredComps);
// Metrics
const totalTarget = beneficiaries.reduce((acc, b) => acc + (b.targetCount || 0), 0);
const totalReached = beneficiaries.reduce((acc, b) => acc + (b.reachedCount || 0), 0);
const reachPercentage = totalTarget > 0 ? Math.min(100, Math.round((totalReached / totalTarget) * 100)) : 0;
const verifiedCompCount = complianceItems.filter((c) => c.status === ApprovalStatus.Approved).length;
// Exports
const benExportColumns: ExportColumn<Beneficiary>[] = [
{ header: 'Beneficiary Name', key: 'name' },
{ header: 'Category', key: 'category' },
{ header: 'Location', key: 'location' },
{ header: 'Target Population', key: 'targetCount' },
{ header: 'Reached Population', key: 'reachedCount' },
];
const compExportColumns: ExportColumn<ComplianceItem>[] = [
{ header: 'Compliance Requirement', key: 'title' },
{ header: 'Requirements Detail', key: 'requirements' },
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
];
const handleOpenEditBen = (b: Beneficiary) => {
setEditingBen(b);
benForm.reset({
name: b.name || '',
category: b.category || 'General',
location: b.location || '',
targetCount: b.targetCount || 0,
reachedCount: b.reachedCount || 0,
});
setIsBenModalOpen(true);
};
const handleOpenEditComp = (c: ComplianceItem) => {
setEditingComp(c);
compForm.reset({
title: c.title || '',
requirements: c.requirements || '',
status: c.status ?? ApprovalStatus.Pending,
});
setIsCompModalOpen(true);
};
return (
<div className="space-y-8 pb-16">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
<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">
Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
<button
onClick={() => {
refetchBens();
refetchComps();
}}
disabled={fetchingBens || fetchingComps}
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
>
<RefreshCw className={`w-3.5 h-3.5 ${fetchingBens || fetchingComps ? 'animate-spin text-blue-400' : ''}`} />
<span>Refresh</span>
</button>
{activeTab === 'beneficiaries' ? (
<ExportButton
data={sortedBens}
columns={benExportColumns}
filename="project_beneficiaries"
title="Beneficiaries Register"
/>
) : (
<ExportButton
data={sortedComps}
columns={compExportColumns}
filename="compliance_items"
title="Compliance Requirements"
/>
)}
<button
onClick={() => {
setEditingBen(null);
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
setIsBenModalOpen(true);
}}
className="flex items-center gap-2 px-4 py-2.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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Register Beneficiary</span>
</button>
<button
onClick={() => {
setEditingComp(null);
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
setIsCompModalOpen(true);
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-slate-800/90 text-white hover:bg-slate-700/80 border border-slate-700 transition cursor-pointer"
>
<CheckSquare className="w-4 h-4 text-emerald-400" />
<span>Add Compliance Item</span>
</button>
</div>
</div>
{/* KPI Cards */}
<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>
<Users className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p>
<p className="text-[11px] text-slate-400">Registered target stakeholder groups</p>
</div>
<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>
<Target className="w-4 h-4 text-indigo-400" />
</div>
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p>
<p className="text-[11px] text-slate-400">Planned participant headcount</p>
</div>
<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>
<Award className="w-4 h-4 text-emerald-400" />
</div>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-emerald-400">{totalReached.toLocaleString()}</p>
<span className="text-xs font-bold text-slate-400">({reachPercentage}%)</span>
</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${reachPercentage}%` }} />
</div>
</div>
<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>
<ShieldCheck className="w-4 h-4 text-cyan-400" />
</div>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-cyan-400">{complianceItems.length}</p>
<span className="text-xs font-medium text-slate-400">({verifiedCompCount} Satisfied)</span>
</div>
<p className="text-[11px] text-slate-400">Statutory & audit verification records</p>
</div>
</div>
{/* Tabs & Filters */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('beneficiaries')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
activeTab === 'beneficiaries'
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Beneficiaries List ({beneficiaries.length})</span>
</button>
<button
onClick={() => setActiveTab('compliance')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
activeTab === 'compliance'
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<CheckSquare className="w-3.5 h-3.5" />
<span>Compliance Items ({complianceItems.length})</span>
</button>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-xs text-slate-400">
<FolderKanban className="w-3.5 h-3.5 text-blue-400" />
<span>Project:</span>
<select
value={selectedProjectId}
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"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 w-40 sm:w-56"
/>
</div>
</div>
</div>
{/* Tab 1: Beneficiaries Table */}
{activeTab === 'beneficiaries' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
Beneficiary / Cohort
</SortableTh>
<SortableTh column="category" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
Category
</SortableTh>
<SortableTh column="location" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
Location
</SortableTh>
<SortableTh column="targetCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
Target Headcount
</SortableTh>
<SortableTh column="reachedCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
Reached Headcount
</SortableTh>
<th className="py-3 px-4">Reach Progress</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{loadingBens ? (
<tr>
<td colSpan={7} 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 beneficiaries...</span>
</div>
</td>
</tr>
) : sortedBens.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-400">
No beneficiaries recorded for this project.
</td>
</tr>
) : (
sortedBens.map((b) => {
const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0;
return (
<tr key={b.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span>{b.name}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300">
<span className="bg-slate-800/80 px-2 py-0.5 rounded-md border border-slate-700/60 text-[11px]">
{b.category || 'General'}
</span>
</td>
<td className="py-3.5 px-4 text-slate-300">
<div className="flex items-center gap-1.5 text-slate-400">
<MapPin className="w-3 h-3 text-slate-500" />
<span>{b.location || 'Not Specified'}</span>
</div>
</td>
<td className="py-3.5 px-4 font-medium text-slate-200">{(b.targetCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4 font-bold text-emerald-400">{(b.reachedCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4">
<div className="flex items-center gap-2 max-w-[140px]">
<div className="flex-1 bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full rounded-full" style={{ width: `${Math.min(100, prog)}%` }} />
</div>
<span className="text-[11px] font-mono text-slate-400">{prog}%</span>
</div>
</td>
<td className="py-3.5 px-4 text-right space-x-2">
<button
onClick={() => {
setSelectedBenForDetail(b);
setIsDetailModalOpen(true);
}}
className="px-2.5 py-1 text-xs font-medium text-blue-400 hover:text-blue-300 bg-blue-950/40 hover:bg-blue-900/40 rounded-lg border border-blue-800/40 transition cursor-pointer"
>
Compliance & Outcomes
</button>
<button
onClick={() => handleOpenEditBen(b)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit beneficiary"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 2: Compliance Items Table */}
{activeTab === 'compliance' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
Compliance Title
</SortableTh>
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
Requirements
</SortableTh>
<SortableTh column="status" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
Verification Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
Created
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{loadingComps ? (
<tr>
<td colSpan={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>
</div>
</td>
</tr>
) : sortedComps.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No compliance items recorded for this project.
</td>
</tr>
) : (
sortedComps.map((c) => (
<tr key={c.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<div className="flex items-center gap-2">
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
<span>{c.title}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(c.status)}</td>
<td className="py-3.5 px-4 text-slate-400">{formatDate(c.createdAt)}</td>
<td className="py-3.5 px-4 text-right">
<button
onClick={() => handleOpenEditComp(c)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit compliance item"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Modal: Create/Edit Beneficiary */}
<Modal
isOpen={isBenModalOpen}
onClose={() => setIsBenModalOpen(false)}
title={editingBen ? 'Edit Beneficiary Profile' : 'Register Project Beneficiary'}
size="md"
>
<form
onSubmit={benForm.handleSubmit((d) => (editingBen ? updateBenMutation.mutate(d) : createBenMutation.mutate(d)))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Beneficiary Name / Group *</label>
<input
{...benForm.register('name')}
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"
placeholder="e.g. Rural Female Farmers Cohort"
/>
{benForm.formState.errors.name && (
<span className="text-[11px] text-rose-400 mt-1 block">{benForm.formState.errors.name.message}</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Category *</label>
<input
{...benForm.register('category')}
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"
placeholder="e.g. Agriculture, Youth"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Location *</label>
<input
{...benForm.register('location')}
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"
placeholder="e.g. Limpopo, District 4"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Count *</label>
<input
type="number"
{...benForm.register('targetCount')}
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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Reached Count</label>
<input
type="number"
{...benForm.register('reachedCount')}
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"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsBenModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createBenMutation.isPending || updateBenMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
>
{editingBen ? 'Update Beneficiary' : 'Save Beneficiary'}
</button>
</div>
</form>
</Modal>
{/* Modal: Create/Edit Compliance Item */}
<Modal
isOpen={isCompModalOpen}
onClose={() => setIsCompModalOpen(false)}
title={editingComp ? 'Edit Compliance Requirement' : 'Add Compliance Requirement'}
size="md"
>
<form
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
<input
{...compForm.register('title')}
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"
placeholder="e.g. Identity Verification & Consent"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Requirements Detail *</label>
<textarea
rows={3}
{...compForm.register('requirements')}
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"
placeholder="Specify requirements and criteria required to satisfy this compliance item..."
/>
</div>
{editingComp && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
<select
{...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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsCompModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createCompMutation.isPending || updateCompMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
>
{editingComp ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
</Modal>
{/* Modal: Beneficiary Detail & Compliance / Outcomes Inspector */}
{selectedBenForDetail && (
<Modal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
title={`Beneficiary: ${selectedBenForDetail.name}`}
size="lg"
>
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Category</span>
<span className="text-xs font-bold text-white">{selectedBenForDetail.category || 'General'}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Location</span>
<span className="text-xs font-bold text-white">{selectedBenForDetail.location || 'National'}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Target Count</span>
<span className="text-xs font-bold text-white">{selectedBenForDetail.targetCount}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Reached</span>
<span className="text-xs font-bold text-emerald-400">{selectedBenForDetail.reachedCount}</span>
</div>
</div>
{/* Compliance Satisfied Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
<FileCheck className="w-4 h-4 text-cyan-400" />
<span>Compliance Requirements Satisfying this Beneficiary</span>
</h4>
</div>
{complianceItems.length === 0 ? (
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
No compliance items recorded for this project yet.
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{complianceItems.map((ci) => (
<div
key={ci.id}
className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between gap-3 text-xs"
>
<div>
<p className="font-semibold text-white">{ci.title}</p>
<p className="text-[11px] text-slate-400 truncate max-w-sm">{ci.requirements}</p>
</div>
<div className="shrink-0">{getApprovalStatusBadge(ci.status)}</div>
</div>
))}
</div>
)}
</div>
{/* Outcomes Section */}
<div className="space-y-3 pt-4 border-t border-slate-800">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-400" />
<span>Recorded Outcomes & Impact ({outcomes.length})</span>
</h4>
<button
onClick={() => setIsOutcomeModalOpen(true)}
className="px-3 py-1 bg-indigo-600/30 hover:bg-indigo-600/50 text-indigo-300 border border-indigo-500/40 rounded-lg text-xs font-semibold transition cursor-pointer"
>
+ Add Outcome
</button>
</div>
{outcomes.length === 0 ? (
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
No outcomes registered for this beneficiary yet.
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{outcomes.map((o) => (
<div key={o.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 text-xs">
<p className="font-semibold text-white">{o.name}</p>
<p className="text-[11px] text-slate-400 mt-0.5">{o.description}</p>
</div>
))}
</div>
)}
</div>
</div>
</Modal>
)}
{/* Modal: Add Outcome */}
<Modal isOpen={isOutcomeModalOpen} onClose={() => setIsOutcomeModalOpen(false)} title="Record Beneficiary Outcome" size="md">
<form onSubmit={outcomeForm.handleSubmit((d) => createOutcomeMutation.mutate(d))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Outcome Name *</label>
<input
{...outcomeForm.register('name')}
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"
placeholder="e.g. 85% Post-Training Employment Rate"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Impact Description *</label>
<textarea
rows={3}
{...outcomeForm.register('description')}
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"
placeholder="Detail quantitative or qualitative impact observed..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsOutcomeModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createOutcomeMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-indigo-600 hover:bg-indigo-500 text-white transition shadow-md shadow-indigo-600/30"
>
Save Outcome
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -0,0 +1,931 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
GitPullRequest,
AlertOctagon,
Plus,
CheckCircle2,
AlertCircle,
RefreshCw,
Search,
Check,
XCircle,
Edit2,
FolderKanban,
Coins,
Calendar,
User,
Clock,
ArrowRight,
} from 'lucide-react';
import {
getProjects,
getProjectIssues,
createIssue,
updateIssue,
getProjectChangeRequests,
createChangeRequest,
updateChangeRequest,
getUsers,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
import {
getApprovalStatusBadge,
getPriorityBadge,
formatDate,
formatZAR,
getUserEmail,
PRIORITY_OPTIONS,
} from '../lib/formatters';
const issueSchema = z.object({
title: z.string().min(3, 'Issue title required'),
description: z.string().min(5, 'Description required'),
priority: z.coerce.number().min(1).max(4),
assignedTo: z.string().min(1, 'Assignee is required'),
});
const resolveIssueSchema = z.object({
resolution: z.string().min(5, 'Resolution notes required'),
status: z.coerce.number(),
});
const changeRequestSchema = z.object({
title: z.string().min(3, 'Title required'),
justification: z.string().min(5, 'Justification required'),
revisedBudget: z.coerce.number().min(0, 'Revised budget must be non-negative'),
revisionDate: z.string().min(4, 'Revision date required'),
});
const resolveChangeSchema = z.object({
status: z.coerce.number(),
justification: z.string().min(3, 'Decision justification required'),
});
type IssueFormValues = z.infer<typeof issueSchema>;
type ResolveIssueFormValues = z.infer<typeof resolveIssueSchema>;
type ChangeRequestFormValues = z.infer<typeof changeRequestSchema>;
type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
const [searchTerm, setSearchTerm] = useState('');
const [priorityFilter, setPriorityFilter] = useState('all');
// Modals
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [editingIssue, setEditingIssue] = useState<Issue | null>(null);
const [isResolveIssueModalOpen, setIsResolveIssueModalOpen] = useState(false);
const [resolvingIssue, setResolvingIssue] = useState<Issue | null>(null);
const [isChangeModalOpen, setIsChangeModalOpen] = useState(false);
const [editingChange, setEditingChange] = useState<ChangeRequest | null>(null);
const [isResolveChangeModalOpen, setIsResolveChangeModalOpen] = useState(false);
const [resolvingChange, setResolvingChange] = useState<ChangeRequest | null>(null);
// Queries
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: users = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
});
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
const {
data: issues = [],
isLoading: loadingIssues,
refetch: refetchIssues,
isFetching: fetchingIssues,
} = useQuery({
queryKey: ['issues', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectIssues(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
});
const {
data: changeRequests = [],
isLoading: loadingChanges,
refetch: refetchChanges,
isFetching: fetchingChanges,
} = useQuery({
queryKey: ['changeRequests', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectChangeRequests(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
});
// Forms
const issueForm = useForm<IssueFormValues>({
resolver: zodResolver(issueSchema),
defaultValues: { title: '', description: '', priority: Priority.Medium, assignedTo: '' },
});
const resolveIssueForm = useForm<ResolveIssueFormValues>({
resolver: zodResolver(resolveIssueSchema),
defaultValues: { resolution: '', status: ActivityStatus.Inactive },
});
const changeForm = useForm<ChangeRequestFormValues>({
resolver: zodResolver(changeRequestSchema),
defaultValues: {
title: '',
justification: '',
revisedBudget: 0,
revisionDate: new Date().toISOString().split('T')[0],
},
});
const resolveChangeForm = useForm<ResolveChangeFormValues>({
resolver: zodResolver(resolveChangeSchema),
defaultValues: { status: ApprovalStatus.Approved, justification: '' },
});
// Mutations
const createIssueMutation = useMutation({
mutationFn: (vals: IssueFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createIssue({
projectId: effectiveProjectId,
title: vals.title,
description: vals.description,
priority: vals.priority,
assignedTo: vals.assignedTo,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
setIsIssueModalOpen(false);
issueForm.reset();
success('Issue Logged', 'New project issue created and assigned.');
},
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to log issue.'),
});
const updateIssueMutation = useMutation({
mutationFn: (vals: IssueFormValues) => {
if (!editingIssue) throw new Error('No issue selected');
return updateIssue({
issueId: editingIssue.id,
title: vals.title,
description: vals.description,
priority: vals.priority,
assignedTo: vals.assignedTo,
resolution: editingIssue.resolution || '',
resolvedAt: editingIssue.resolvedAt || null,
status: editingIssue.status || ActivityStatus.Active,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
setIsIssueModalOpen(false);
setEditingIssue(null);
issueForm.reset();
success('Issue Updated', 'Issue record saved.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'),
});
const resolveIssueMutation = useMutation({
mutationFn: (vals: ResolveIssueFormValues) => {
if (!resolvingIssue) throw new Error('No issue selected');
return updateIssue({
issueId: resolvingIssue.id,
title: resolvingIssue.title || '',
description: resolvingIssue.description || '',
priority: resolvingIssue.priority || Priority.Medium,
assignedTo: resolvingIssue.assignedTo || '',
resolution: vals.resolution,
resolvedAt: new Date().toISOString(),
status: vals.status,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
setIsResolveIssueModalOpen(false);
setResolvingIssue(null);
resolveIssueForm.reset();
success('Issue Resolved', 'Resolution recorded and issue status updated.');
},
onError: (err: any) => toastError('Resolution Failed', err.message || 'Unable to resolve issue.'),
});
const createChangeMutation = useMutation({
mutationFn: (vals: ChangeRequestFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createChangeRequest({
projectId: effectiveProjectId,
title: vals.title,
justification: vals.justification,
revisedBudget: vals.revisedBudget,
revisionDate: vals.revisionDate,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['changeRequests'] });
setIsChangeModalOpen(false);
changeForm.reset();
success('Change Request Submitted', 'Formal change request submitted for review.');
},
onError: (err: any) => toastError('Submission Failed', err.message || 'Unable to submit change request.'),
});
const resolveChangeMutation = useMutation({
mutationFn: (vals: ResolveChangeFormValues) => {
if (!resolvingChange) throw new Error('No change request selected');
return updateChangeRequest({
changeRequestId: resolvingChange.id,
title: resolvingChange.title || '',
justification: vals.justification || resolvingChange.justification || '',
revisedBudget: resolvingChange.revisedBudget || 0,
status: vals.status,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['changeRequests'] });
setIsResolveChangeModalOpen(false);
setResolvingChange(null);
resolveChangeForm.reset();
success('Change Decision Applied', 'Change request decision successfully resolved.');
},
onError: (err: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'),
});
// Filtered & Sorted Issues
const filteredIssues = useMemo(() => {
return issues.filter((iss) => {
const matchSearch =
!searchTerm.trim() ||
(iss.title && iss.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
(iss.description && iss.description.toLowerCase().includes(searchTerm.toLowerCase()));
const matchPrio = priorityFilter === 'all' || String(iss.priority) === String(priorityFilter);
return matchSearch && matchPrio;
});
}, [issues, searchTerm, priorityFilter]);
const { sortedData: sortedIssues, sortColumn: issueSortCol, sortDirection: issueSortDir, handleSort: handleIssueSort } =
useTableSort(filteredIssues);
// Filtered & Sorted Change Requests
const filteredChanges = useMemo(() => {
return changeRequests.filter((cr) => {
return (
!searchTerm.trim() ||
(cr.title && cr.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
(cr.justification && cr.justification.toLowerCase().includes(searchTerm.toLowerCase()))
);
});
}, [changeRequests, searchTerm]);
const { sortedData: sortedChanges, sortColumn: changeSortCol, sortDirection: changeSortDir, handleSort: handleChangeSort } =
useTableSort(filteredChanges);
// Exports
const issueExportCols: ExportColumn<Issue>[] = [
{ header: 'Title', key: 'title' },
{ header: 'Description', key: 'description' },
{ header: 'Priority', accessor: (i) => (i.priority === Priority.Critical ? 'Critical' : 'Standard') },
{ header: 'Assigned To', accessor: (i) => (i.assignedTo ? getUserEmail(i.assignedTo) : 'Unassigned') },
{ header: 'Status', accessor: (i) => (i.status === ActivityStatus.Active ? 'Active' : 'Resolved') },
];
const changeExportCols: ExportColumn<ChangeRequest>[] = [
{ header: 'Change Title', key: 'title' },
{ header: 'Justification', key: 'justification' },
{ header: 'Revised Budget', accessor: (c) => formatZAR(c.revisedBudget || 0) },
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
];
const openResolveIssue = (iss: Issue) => {
setResolvingIssue(iss);
resolveIssueForm.reset({ resolution: iss.resolution || '', status: ActivityStatus.Inactive });
setIsResolveIssueModalOpen(true);
};
const openResolveChange = (cr: ChangeRequest, statusToApply: ApprovalStatus) => {
setResolvingChange(cr);
resolveChangeForm.reset({
status: statusToApply,
justification: `Approved by Project Governance for execution.`,
});
setIsResolveChangeModalOpen(true);
};
return (
<div className="space-y-8 pb-16">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-amber-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-amber-600/20 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-lg shadow-amber-500/10">
<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">
Track project execution impediments, review contractual variations, and resolve change requests in-place.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
<button
onClick={() => {
refetchIssues();
refetchChanges();
}}
disabled={fetchingIssues || fetchingChanges}
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
>
<RefreshCw className={`w-3.5 h-3.5 ${fetchingIssues || fetchingChanges ? 'animate-spin text-amber-400' : ''}`} />
<span>Refresh</span>
</button>
{activeTab === 'issues' ? (
<ExportButton data={sortedIssues} columns={issueExportCols} filename="project_issues" title="Project Issues" />
) : (
<ExportButton data={sortedChanges} columns={changeExportCols} filename="change_requests" title="Change Requests" />
)}
<button
onClick={() => {
setEditingIssue(null);
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
setIsIssueModalOpen(true);
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-lg shadow-amber-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Log Issue</span>
</button>
<button
onClick={() => {
setEditingChange(null);
changeForm.reset({
title: '',
justification: '',
revisedBudget: 0,
revisionDate: new Date().toISOString().split('T')[0],
});
setIsChangeModalOpen(true);
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-slate-800/90 text-white hover:bg-slate-700/80 border border-slate-700 transition cursor-pointer"
>
<GitPullRequest className="w-4 h-4 text-blue-400" />
<span>Request Change</span>
</button>
</div>
</div>
{/* KPI Cards */}
<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>
<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>
<p className="text-[11px] text-slate-400">Open project impediments</p>
</div>
<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>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-emerald-400">
{issues.filter((i) => i.status === ActivityStatus.Inactive).length}
</p>
<p className="text-[11px] text-slate-400">Closed with documented resolution</p>
</div>
<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>
<GitPullRequest className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
{changeRequests.filter((c) => c.status === ApprovalStatus.Pending || c.status === ApprovalStatus.Review).length}
</p>
<p className="text-[11px] text-slate-400">Awaiting governance review</p>
</div>
<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>
<Coins className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">
{changeRequests.filter((c) => c.status === ApprovalStatus.Approved).length}
</p>
<p className="text-[11px] text-slate-400">Contract amendments enacted</p>
</div>
</div>
{/* Tabs & Filters */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('issues')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
activeTab === 'issues'
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<AlertOctagon className="w-3.5 h-3.5" />
<span>Project Issues ({issues.length})</span>
</button>
<button
onClick={() => setActiveTab('changes')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
activeTab === 'changes'
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<GitPullRequest className="w-3.5 h-3.5" />
<span>Change Requests ({changeRequests.length})</span>
</button>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-xs text-slate-400">
<FolderKanban className="w-3.5 h-3.5 text-amber-400" />
<span>Project:</span>
<select
value={selectedProjectId}
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-amber-500"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 w-40 sm:w-56"
/>
</div>
</div>
</div>
{/* Tab 1: Issues Table */}
{activeTab === 'issues' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
Issue Summary
</SortableTh>
<SortableTh column="priority" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
Priority
</SortableTh>
<SortableTh column="assignedTo" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
Assigned To
</SortableTh>
<SortableTh column="status" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
Status
</SortableTh>
<th className="py-3 px-4">Resolution Notes</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{loadingIssues ? (
<tr>
<td colSpan={6} 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-amber-500 border-t-transparent rounded-full animate-spin" />
<span>Loading project issues...</span>
</div>
</td>
</tr>
) : sortedIssues.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-slate-400">
No issues recorded for this project.
</td>
</tr>
) : (
sortedIssues.map((iss) => {
const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt;
return (
<tr key={iss.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<div className="flex items-start gap-2.5">
<AlertOctagon
className={`w-4 h-4 mt-0.5 shrink-0 ${isResolved ? 'text-emerald-400' : 'text-rose-400'}`}
/>
<div>
<p className="font-semibold text-white">{iss.title}</p>
<p className="text-[11px] text-slate-400 font-normal line-clamp-1">{iss.description}</p>
</div>
</div>
</td>
<td className="py-3.5 px-4">{getPriorityBadge(iss.priority)}</td>
<td className="py-3.5 px-4 text-slate-300">
<div className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-slate-400" />
<span className="truncate max-w-[150px]">{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}</span>
</div>
</td>
<td className="py-3.5 px-4">
{isResolved ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" /> Resolved
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
<Clock className="w-3 h-3" /> Open
</span>
)}
</td>
<td className="py-3.5 px-4 text-slate-400 max-w-xs truncate">
{iss.resolution || <span className="italic text-slate-600">Pending resolution...</span>}
</td>
<td className="py-3.5 px-4 text-right space-x-2">
<button
onClick={() => openResolveIssue(iss)}
className="px-2.5 py-1 text-xs font-medium text-amber-400 hover:text-amber-300 bg-amber-950/40 hover:bg-amber-900/40 rounded-lg border border-amber-800/40 transition cursor-pointer"
>
Resolve
</button>
<button
onClick={() => {
setEditingIssue(iss);
issueForm.reset({
title: iss.title || '',
description: iss.description || '',
priority: iss.priority || Priority.Medium,
assignedTo: iss.assignedTo || '',
});
setIsIssueModalOpen(true);
}}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit issue"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 2: Change Requests Table */}
{activeTab === 'changes' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
Change Proposal
</SortableTh>
<SortableTh column="justification" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
Justification
</SortableTh>
<SortableTh column="revisedBudget" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
Revised Budget
</SortableTh>
<SortableTh column="status" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
Governance Status
</SortableTh>
<th className="py-3 px-4 text-right">In-Place Decision</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{loadingChanges ? (
<tr>
<td colSpan={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-amber-500 border-t-transparent rounded-full animate-spin" />
<span>Loading change requests...</span>
</div>
</td>
</tr>
) : sortedChanges.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No change requests logged for this project.
</td>
</tr>
) : (
sortedChanges.map((cr) => (
<tr key={cr.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<div className="flex items-center gap-2">
<GitPullRequest className="w-4 h-4 text-amber-400 shrink-0" />
<span>{cr.title}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={cr.justification || ''}>
{cr.justification || 'None'}
</td>
<td className="py-3.5 px-4 font-mono font-semibold text-slate-200">
{formatZAR(cr.revisedBudget || 0)}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(cr.status)}</td>
<td className="py-3.5 px-4 text-right space-x-2">
{cr.status === ApprovalStatus.Approved ? (
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
Resolved & Enacted
</span>
) : (
<div className="inline-flex items-center gap-1.5">
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<Check className="w-3 h-3" /> Approve
</button>
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
>
<XCircle className="w-3 h-3" /> Reject
</button>
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Modal: Create/Edit Issue */}
<Modal
isOpen={isIssueModalOpen}
onClose={() => setIsIssueModalOpen(false)}
title={editingIssue ? 'Edit Project Issue' : 'Log Project Issue'}
size="md"
>
<form
onSubmit={issueForm.handleSubmit((d) => (editingIssue ? updateIssueMutation.mutate(d) : createIssueMutation.mutate(d)))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Issue Title *</label>
<input
{...issueForm.register('title')}
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-amber-500"
placeholder="e.g. Procurement delays for regional solar supplies"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
<textarea
rows={3}
{...issueForm.register('description')}
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-amber-500"
placeholder="Detail root cause and operational impact..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority</label>
<select
{...issueForm.register('priority')}
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-amber-500"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Assigned Person *</label>
<select
{...issueForm.register('assignedTo')}
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-amber-500"
>
<option value="">Select Assignee</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email || u.id}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsIssueModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createIssueMutation.isPending || updateIssueMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
>
{editingIssue ? 'Update Issue' : 'Log Issue'}
</button>
</div>
</form>
</Modal>
{/* Modal: In-place Resolve Issue */}
<Modal
isOpen={isResolveIssueModalOpen}
onClose={() => setIsResolveIssueModalOpen(false)}
title={`Resolve Issue: ${resolvingIssue?.title || ''}`}
size="md"
>
<form onSubmit={resolveIssueForm.handleSubmit((d) => resolveIssueMutation.mutate(d))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Resolution Summary & Remediation Notes *</label>
<textarea
rows={4}
{...resolveIssueForm.register('resolution')}
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-amber-500"
placeholder="Explain how the issue was resolved or mitigated..."
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Status Transition</label>
<select
{...resolveIssueForm.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-amber-500"
>
<option value={ActivityStatus.Inactive}>Resolved & Closed</option>
<option value={ActivityStatus.Active}>Keep Open with Progress Notes</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsResolveIssueModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={resolveIssueMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white transition shadow-md shadow-emerald-600/30"
>
Confirm Resolution
</button>
</div>
</form>
</Modal>
{/* Modal: Create Change Request */}
<Modal
isOpen={isChangeModalOpen}
onClose={() => setIsChangeModalOpen(false)}
title="Request Contractual & Budgetary Change"
size="md"
>
<form onSubmit={changeForm.handleSubmit((d) => createChangeMutation.mutate(d))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Change Proposal Title *</label>
<input
{...changeForm.register('title')}
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-amber-500"
placeholder="e.g. Scope expansion to include 2 additional clinics"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Business Justification *</label>
<textarea
rows={3}
{...changeForm.register('justification')}
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-amber-500"
placeholder="Provide statutory and contractual justification..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Revised Total Budget (ZAR) *</label>
<input
type="number"
{...changeForm.register('revisedBudget')}
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-amber-500"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Revision Date *</label>
<input
type="date"
{...changeForm.register('revisionDate')}
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-amber-500"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsChangeModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createChangeMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
>
Submit Change
</button>
</div>
</form>
</Modal>
{/* Modal: Resolve Change Request Decision */}
<Modal
isOpen={isResolveChangeModalOpen}
onClose={() => setIsResolveChangeModalOpen(false)}
title={`Resolve Change: ${resolvingChange?.title || ''}`}
size="md"
>
<form onSubmit={resolveChangeForm.handleSubmit((d) => resolveChangeMutation.mutate(d))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Decision Justification / Sign-off Comments *</label>
<textarea
rows={3}
{...resolveChangeForm.register('justification')}
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-amber-500"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsResolveChangeModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={resolveChangeMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
>
Apply Decision
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -30,6 +30,7 @@ import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/form
import { ApprovalStatus } from '../types';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useTableSort, SortableTh } from '../utils/tableSort';
export const ExecutiveDashboard: React.FC = () => {
const queryClient = useQueryClient();
@@ -328,6 +329,19 @@ export const ExecutiveDashboard: React.FC = () => {
.slice(0, 5);
}, [projects, filteredProjects, risks]);
const {
sortedData: sortedAttentionProjects,
sortColumn: projectSortCol,
sortDirection: projectSortDir,
handleSort: handleProjectSort,
} = useTableSort<any>(attentionProjects, null, null, {
name: (p: any) => p.name,
programme: (p: any) => p.programme,
status: (p: any) => p.status,
risk: (p: any) => p.risk,
financialVariance: (p: any) => p.financialVariance,
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Title & Filter Bar */}
@@ -767,23 +781,33 @@ export const ExecutiveDashboard: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Project</th>
<th className="py-3.5 px-4">Programme</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4 text-center">Risk</th>
<th className="py-3.5 px-4 text-right">Financial Variance</th>
<th className="py-3.5 px-5 text-right">Action</th>
<SortableTh column="name" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-5">
Project
</SortableTh>
<SortableTh column="programme" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
Programme
</SortableTh>
<SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center">
Risk
</SortableTh>
<SortableTh column="financialVariance" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
Financial Variance
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{attentionProjects.length === 0 ? (
{sortedAttentionProjects.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
</td>
</tr>
) : (
attentionProjects.map((p) => (
sortedAttentionProjects.map((p) => (
<tr key={p.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{p.name}</span>
@@ -57,6 +57,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
import {
@@ -636,6 +637,36 @@ export const GovernancePage: React.FC = () => {
return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q);
});
// Table Sorting Hooks
const {
sortedData: sortedOrgs,
sortColumn: orgSortCol,
sortDirection: orgSortDir,
handleSort: handleOrgSort,
} = useTableSort(filteredOrgs, null, null, {
name: (o) => o.name,
registrationNo: (o) => o.registrationNo,
type: (o) => getOrganisationTypeLabel(o.type),
email: (o) => o.email,
status: (o) => getOrganisationStatusBadge(o).label,
});
const {
sortedData: sortedProgrammes,
sortColumn: progSortCol,
sortDirection: progSortDir,
handleSort: handleProgSort,
} = useTableSort(filteredProgrammes, null, null, {
name: (p) => p.name,
portfolioId: (p) => {
const port = portfolios.find((po) => String(po.id) === String(p.portfolioId));
return port ? port.name : `Portfolio #${p.portfolioId}`;
},
status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy),
});
// Export Columns Definitions
const orgExportColumns: ExportColumn<Organisation>[] = [
{ header: 'Organisation ID', key: 'id' },
@@ -891,12 +922,22 @@ export const GovernancePage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Organisation Name</th>
<th className="py-3.5 px-4">Registration No</th>
<th className="py-3.5 px-4">Entity Type</th>
<th className="py-3.5 px-4">Contact Email</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
<SortableTh column="name" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-5">
Organisation Name
</SortableTh>
<SortableTh column="registrationNo" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Registration No
</SortableTh>
<SortableTh column="type" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Entity Type
</SortableTh>
<SortableTh column="email" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Contact Email
</SortableTh>
<SortableTh column="status" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Status
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
@@ -906,14 +947,14 @@ export const GovernancePage: React.FC = () => {
Loading registered organisations...
</td>
</tr>
) : filteredOrgs.length === 0 ? (
) : sortedOrgs.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No organisations found matching query.
</td>
</tr>
) : (
filteredOrgs.map((org) => {
sortedOrgs.map((org) => {
const statusBadge = getOrganisationStatusBadge(org);
return (
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
@@ -1147,12 +1188,22 @@ export const GovernancePage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Programme Name</th>
<th className="py-3.5 px-4">Parent Portfolio</th>
<th className="py-3.5 px-4">Governance Status</th>
<th className="py-3.5 px-4">Creation Date</th>
<th className="py-3.5 px-4">Owner Email</th>
<th className="py-3.5 px-5 text-right">Actions</th>
<SortableTh column="name" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-5">
Programme Name
</SortableTh>
<SortableTh column="portfolioId" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Parent Portfolio
</SortableTh>
<SortableTh column="status" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Governance Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Creation Date
</SortableTh>
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
@@ -1162,14 +1213,14 @@ export const GovernancePage: React.FC = () => {
Loading programmes...
</td>
</tr>
) : filteredProgrammes.length === 0 ? (
) : sortedProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
) : (
filteredProgrammes.map((pr) => {
sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
return (
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import {
Award,
Budget,
@@ -592,6 +593,49 @@ export const GrantsPage: React.FC = () => {
return id.includes(q) || projId.includes(q) || projName.includes(q) || ref.includes(q) || msId.includes(q) || creator.includes(q);
});
// Table Sorting Hooks
const {
sortedData: sortedAwards,
sortColumn: awardSortCol,
sortDirection: awardSortDir,
handleSort: handleAwardSort,
} = useTableSort(filteredAwards, null, null, {
referenceNo: (a) => a.referenceNo,
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
amount: (a) => a.amount,
status: (a) => getApprovalStatusBadge(a.status).label,
createdAt: (a) => a.createdAt,
});
const {
sortedData: sortedBudgets,
sortColumn: budgetSortCol,
sortDirection: budgetSortDir,
handleSort: handleBudgetSort,
} = useTableSort(filteredBudgets, null, null, {
name: (b) => b.name,
projectId: (b) => b.projectId,
amount: (b) => b.amount,
createdAt: (b) => b.createdAt,
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
createdBy: (b) => getUserEmail(b.createdBy),
});
const {
sortedData: sortedDisbursements,
sortColumn: disbSortCol,
sortDirection: disbSortDir,
handleSort: handleDisbSort,
} = useTableSort(filteredDisbursements, null, null, {
id: (d) => d.id,
projectId: (d) => d.projectId,
milestoneId: (d) => d.milestoneId || 0,
amount: (d) => d.amount,
paymentReference: (d) => d.paymentReference || '',
status: (d) => getApprovalStatusBadge(d.status).label,
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header & Execution Pipeline Summary */}
@@ -766,12 +810,24 @@ export const GrantsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Approval Reference #</th>
<th className="py-3.5 px-4">Recipient Organisation</th>
<th className="py-3.5 px-4">Funding Programme</th>
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Date Registered</th>
<SortableTh column="referenceNo" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-5">
Approval Reference #
</SortableTh>
<SortableTh column="organisationId" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-4">
Recipient Organisation
</SortableTh>
<SortableTh column="programmeId" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-4">
Funding Programme
</SortableTh>
<SortableTh column="amount" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR)
</SortableTh>
<SortableTh column="status" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} align="right" className="py-3.5 px-5 text-right">
Date Registered
</SortableTh>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -781,14 +837,14 @@ export const GrantsPage: React.FC = () => {
Loading registered awards...
</td>
</tr>
) : filteredAwards.length === 0 ? (
) : sortedAwards.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
</td>
</tr>
) : (
filteredAwards.map((awd) => {
sortedAwards.map((awd) => {
const statusBadge = getApprovalStatusBadge(awd.status);
return (
<tr key={awd.id} className="hover:bg-slate-800/40 transition-colors">
@@ -867,13 +923,25 @@ export const GrantsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Budget Line Category</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
<th className="py-3.5 px-4">Date Established</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Created By</th>
<th className="py-3.5 px-5 text-right">Actions</th>
<SortableTh column="name" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-5">
Budget Line Category
</SortableTh>
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR)
</SortableTh>
<SortableTh column="createdAt" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Date Established
</SortableTh>
<SortableTh column="status" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="createdBy" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -883,14 +951,14 @@ export const GrantsPage: React.FC = () => {
Loading budget lines...
</td>
</tr>
) : filteredBudgets.length === 0 ? (
) : sortedBudgets.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
{searchTerm ? 'No budget lines match your search query.' : 'No budget lines established yet.'}
</td>
</tr>
) : (
filteredBudgets.map((b) => {
sortedBudgets.map((b) => {
const statusBadge = getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved);
return (
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
@@ -1005,13 +1073,25 @@ export const GrantsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Disbursement ID</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4">Milestone Association</th>
<th className="py-3.5 px-4 text-right">Tranche Amount</th>
<th className="py-3.5 px-4">Payment Reference</th>
<th className="py-3.5 px-4">Approval Status</th>
<th className="py-3.5 px-5 text-right">Approval Action</th>
<SortableTh column="id" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-5">
Disbursement ID
</SortableTh>
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Milestone Association
</SortableTh>
<SortableTh column="amount" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} align="right" className="py-3.5 px-4 text-right">
Tranche Amount
</SortableTh>
<SortableTh column="paymentReference" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Payment Reference
</SortableTh>
<SortableTh column="status" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Approval Status
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Approval Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -1021,14 +1101,14 @@ export const GrantsPage: React.FC = () => {
Loading disbursements...
</td>
</tr>
) : filteredDisbursements.length === 0 ? (
) : sortedDisbursements.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
{searchTerm ? 'No disbursements match your search query.' : 'No disbursement tranches recorded yet.'}
</td>
</tr>
) : (
filteredDisbursements.map((d) => {
sortedDisbursements.map((d) => {
const statusBadge = getApprovalStatusBadge(d.status);
const isApproved = d.status === ApprovalStatus.Approved;
return (
@@ -41,6 +41,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
@@ -373,6 +374,37 @@ export const MonitoringPage: React.FC = () => {
return name.includes(q) || desc.includes(q) || projId.includes(q) || owner.includes(q) || mitig.includes(q);
});
// Table Sorting Hooks
const {
sortedData: sortedIndicators,
sortColumn: indSortCol,
sortDirection: indSortDir,
handleSort: handleIndSort,
} = useTableSort(filteredIndicators, null, null, {
name: (ind) => ind.name,
projectId: (ind) => ind.projectId,
unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure),
baselineAmount: (ind) => ind.baselineAmount,
targetAmount: (ind) => ind.targetAmount,
actualAmount: (ind) => ind.actualAmount,
status: (ind) => getIndicatorStatus(ind.actualAmount, ind.targetAmount).label,
});
const {
sortedData: sortedRisks,
sortColumn: riskSortCol,
sortDirection: riskSortDir,
handleSort: handleRiskSort,
} = useTableSort(filteredRisks, null, null, {
name: (rk) => rk.name,
projectId: (rk) => rk.projectId,
likelihood: (rk) => rk.likelihood,
impact: (rk) => rk.impact,
residualScore: (rk) => rk.likelihood * rk.impact,
owner: (rk) => getUserEmail(rk.owner),
mitigationAction: (rk) => rk.mitigationAction || '',
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
@@ -643,14 +675,28 @@ export const MonitoringPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Indicator Name</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4">Unit of Measure</th>
<th className="py-3.5 px-4 text-right">Baseline</th>
<th className="py-3.5 px-4 text-right">Target</th>
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Action</th>
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
Indicator Name
</SortableTh>
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Unit of Measure
</SortableTh>
<SortableTh column="baselineAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Baseline
</SortableTh>
<SortableTh column="targetAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Target
</SortableTh>
<SortableTh column="actualAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
Actual Recorded
</SortableTh>
<SortableTh column="status" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
Status
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -661,7 +707,7 @@ export const MonitoringPage: React.FC = () => {
Loading project indicators from API...
</td>
</tr>
) : filteredIndicators.length === 0 ? (
) : sortedIndicators.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
@@ -671,7 +717,7 @@ export const MonitoringPage: React.FC = () => {
</td>
</tr>
) : (
filteredIndicators.map((ind) => {
sortedIndicators.map((ind) => {
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
return (
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
@@ -833,14 +879,28 @@ export const MonitoringPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Risk Title & Description</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
<th className="py-3.5 px-4 text-center">Residual Score</th>
<th className="py-3.5 px-4">Risk Owner</th>
<th className="py-3.5 px-5">Mitigation Control</th>
<th className="py-3.5 px-4 text-right">Actions</th>
<SortableTh column="name" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-5">
Risk Title & Description
</SortableTh>
<SortableTh column="projectId" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="likelihood" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Likelihood (1–5)
</SortableTh>
<SortableTh column="impact" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Impact (1–5)
</SortableTh>
<SortableTh column="residualScore" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Residual Score
</SortableTh>
<SortableTh column="owner" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-4">
Risk Owner
</SortableTh>
<SortableTh column="mitigationAction" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-5">
Mitigation Control
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -851,7 +911,7 @@ export const MonitoringPage: React.FC = () => {
Loading risk register from API...
</td>
</tr>
) : filteredRisks.length === 0 ? (
) : sortedRisks.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<ShieldAlert className="w-8 h-8 text-slate-600 mx-auto mb-2" />
@@ -861,7 +921,7 @@ export const MonitoringPage: React.FC = () => {
</td>
</tr>
) : (
filteredRisks.map((rk) => {
sortedRisks.map((rk) => {
const score = rk.likelihood * rk.impact;
const color = getRiskScoreColor(rk.likelihood, rk.impact);
return (
@@ -59,6 +59,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useAuth } from '../auth/AuthProvider';
import {
EmailStatuses,
@@ -139,7 +140,7 @@ export const SettingsPage: React.FC = () => {
const syncMutation = useMutation({
mutationFn: () => syncUser(undefined, true),
onSuccess: () => {
success('User Sync Complete', 'Authenticated user enabled and directory synced. Browser cache updated to prevent endpoint hotspot.');
success('User Sync Complete', 'Authenticated user enabled and directory synced.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
@@ -320,6 +321,13 @@ export const SettingsPage: React.FC = () => {
);
});
const {
sortedData: sortedNotifications,
sortColumn: notifSortCol,
sortDirection: notifSortDir,
handleSort: handleNotifSort,
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
// ==========================================
// 4. Audit Log Ledger State
// ==========================================
@@ -417,6 +425,27 @@ export const SettingsPage: React.FC = () => {
);
});
const {
sortedData: sortedAuditLogs,
sortColumn: auditSortCol,
sortDirection: auditSortDir,
handleSort: handleAuditSort,
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
const {
sortedData: sortedUsers,
sortColumn: userSortCol,
sortDirection: userSortDir,
handleSort: handleUserSort,
} = useTableSort(usersList, 'email', 'asc');
const {
sortedData: sortedInvites,
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(invitesList, 'createdAt', 'desc');
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
@@ -488,51 +517,16 @@ export const SettingsPage: React.FC = () => {
)}
{activeTab === 'notifications' && (
<div className="flex items-center gap-2">
<button
onClick={() => {
refetchNotifications();
success('Notifications Refreshed', 'Latest communication transmissions loaded from endpoint.');
}}
disabled={isFetchingNotifications}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
title="Force refresh notifications from endpoint"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingNotifications ? 'animate-spin' : ''}`} />
<span>Refresh Notifications</span>
</button>
<ExportButton
title="Communications & Notifications Ledger"
subtitle="Transmission telemetry report via /v1/api/communications/norifications"
filename={`pfm-notifications-report-${new Date().toISOString().split('T')[0]}`}
columns={notificationExportColumns}
data={filteredNotifications}
/>
</div>
)}
{activeTab === 'audit' && (
<div className="flex items-center gap-2">
<button
onClick={() => {
refetchAuditLogs();
success('Audit Ledger Refreshed', 'Latest audit log entries loaded from backend.');
}}
disabled={isFetchingAuditLogs}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
title="Force refresh audit logs from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAuditLogs ? 'animate-spin' : ''}`} />
<span>Refresh Audit Logs</span>
</button>
<ExportButton
title="System Audit & Security Log"
subtitle="Complete audit trail of user access, statutory approvals, and financial transactions"
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
columns={auditExportColumns}
data={filteredAuditLogs}
/>
</div>
<button
onClick={() => {
setInviteOrgId(effectiveNotificationOrgId);
setIsSendInviteModalOpen(true);
}}
className="inline-flex items-center gap-1.5 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 cursor-pointer"
>
<Plus className="w-4 h-4" />
Add Notification
</button>
)}
</div>
</div>
@@ -659,10 +653,18 @@ export const SettingsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">User Email</th>
<th className="py-3.5 px-4">User Identifier</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Last Login</th>
<SortableTh column="email" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-5">
User Email
</SortableTh>
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
User Identifier
</SortableTh>
<SortableTh column="status" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="lastLoginAt" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
Last Login
</SortableTh>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
@@ -673,14 +675,14 @@ export const SettingsPage: React.FC = () => {
Loading user directory...
</td>
</tr>
) : usersList.length === 0 ? (
) : sortedUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No users found matching current filters.
</td>
</tr>
) : (
usersList.map((u) => {
sortedUsers.map((u) => {
const badge = getActivityStatusBadge(u.status);
return (
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
@@ -831,11 +833,21 @@ export const SettingsPage: React.FC = () => {
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Recipient (UUID)</th>
<th className="py-3.5 px-4">Channel</th>
<th className="py-3.5 px-4">Message</th>
<th className="py-3.5 px-4">Target Org / Grant</th>
<th className="py-3.5 px-5 text-right">Created Date</th>
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
Recipient (UUID)
</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>
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
Created Date
</SortableTh>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -845,14 +857,14 @@ export const SettingsPage: React.FC = () => {
Loading invitations ledger...
</td>
</tr>
) : invitesList.length === 0 ? (
) : sortedInvites.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
) : (
invitesList.map((inv, idx) => (
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}
@@ -919,7 +931,7 @@ export const SettingsPage: React.FC = () => {
Total Notifications
</span>
<div className="text-xl font-bold font-mono text-white">{fetchedNotifications.length}</div>
<span className="text-[10px] text-slate-500">Returned by endpoint</span>
<span className="text-[10px] text-slate-500">System records</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
@@ -966,19 +978,19 @@ export const SettingsPage: React.FC = () => {
</div>
{/* Filter & Search Toolbar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md 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">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
value={notificationSearchTerm}
onChange={(e) => setNotificationSearchTerm(e.target.value)}
placeholder="Search notifications by subject, message, recipient, destination..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm shadow-xl flex flex-col lg:flex-row lg:items-center justify-between gap-3 relative z-20">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 flex-1 flex-wrap">
<div className="relative flex-1 min-w-[240px] max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
value={notificationSearchTerm}
onChange={(e) => setNotificationSearchTerm(e.target.value)}
placeholder="Search notifications by subject, message, recipient, destination..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Target Org:</span>
<Building2 className="w-3.5 h-3.5 text-blue-400" />
@@ -1045,7 +1057,9 @@ export const SettingsPage: React.FC = () => {
Reset
</button>
)}
</div>
<div className="flex items-center gap-2.5 self-end sm:self-center shrink-0 flex-wrap">
<button
type="button"
onClick={() => {
@@ -1080,13 +1094,13 @@ export const SettingsPage: React.FC = () => {
</h3>
<HelpTooltip
title="Communications Telemetry"
content="Real-time notification reports returned via /v1/api/communications/norifications/{organisationId}. Displays live delivery status across Email, SMS, WhatsApp, Webhooks, and In-App channels."
content="Real-time notification reports. Displays live delivery status across Email, SMS, WhatsApp, Webhooks, and In-App channels."
size="sm"
placement="right"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
GET /v1/api/communications/norifications/{effectiveNotificationOrgId}
<span className="text-xs text-slate-400 font-mono">
Org #{effectiveNotificationOrgId}
</span>
</div>
@@ -1094,14 +1108,28 @@ export const SettingsPage: React.FC = () => {
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400 font-medium">
<th className="py-3.5 px-4">Notification ID</th>
<th className="py-3.5 px-4">Channel / Platform</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Subject &amp; Message Payload</th>
<th className="py-3.5 px-4">Recipient &amp; Destination</th>
<th className="py-3.5 px-4">Target Org</th>
<th className="py-3.5 px-4">Timestamp</th>
<th className="py-3.5 px-4 text-right">Actions</th>
<SortableTh column="id" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Notification ID
</SortableTh>
<SortableTh column="platform" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Channel / Platform
</SortableTh>
<SortableTh column="status" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="subject" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Subject &amp; Message Payload
</SortableTh>
<SortableTh column="recipient" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Recipient &amp; Destination
</SortableTh>
<SortableTh column="organisationId" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Target Org
</SortableTh>
<SortableTh column="createdAt" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
Timestamp
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -1109,10 +1137,10 @@ export const SettingsPage: React.FC = () => {
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Querying communication microservice endpoints for Organisation #{effectiveNotificationOrgId}...
Loading communication records for Organisation #{effectiveNotificationOrgId}...
</td>
</tr>
) : filteredNotifications.length === 0 ? (
) : sortedNotifications.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
@@ -1120,7 +1148,7 @@ export const SettingsPage: React.FC = () => {
</td>
</tr>
) : (
filteredNotifications.map((item) => {
sortedNotifications.map((item) => {
const statusBadge = getNotificationStatusBadge(item.status);
return (
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
@@ -1332,13 +1360,13 @@ export const SettingsPage: React.FC = () => {
</h3>
<HelpTooltip
title="Audit Trail"
content="Authoritative record of state changes retrieved via POST /v1/api/governance/auditlog."
content="Authoritative record of state changes and system activities."
size="sm"
placement="right"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
POST /v1/api/governance/auditlog
<span className="text-xs text-slate-400 font-mono">
{filteredAuditLogs.length} entries
</span>
</div>
@@ -1347,13 +1375,25 @@ 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">
<th className="py-3.5 px-3 w-10 text-center"></th>
<th className="py-3.5 px-4">Timestamp</th>
<th className="py-3.5 px-4">Event ID</th>
<th className="py-3.5 px-4">Action</th>
<th className="py-3.5 px-4">Entity</th>
<th className="py-3.5 px-4">Performed By</th>
<th className="py-3.5 px-4">Changes</th>
<th className="py-3.5 px-5 text-right">Inspect</th>
<SortableTh column="timestamp" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Timestamp
</SortableTh>
<SortableTh column="id" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Event ID
</SortableTh>
<SortableTh column="action" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Action
</SortableTh>
<SortableTh column="entityName" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Entity
</SortableTh>
<SortableTh column="performedBy" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Performed By
</SortableTh>
<SortableTh column="changes" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
Changes
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Inspect</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
@@ -1361,20 +1401,20 @@ export const SettingsPage: React.FC = () => {
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Querying governance audit log endpoints...
Loading governance audit records...
</td>
</tr>
) : filteredAuditLogs.length === 0 ? (
) : sortedAuditLogs.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
? 'No audit log entries match your filter criteria.'
: 'No audit records returned from endpoint.'}
: 'No audit records found.'}
</td>
</tr>
) : (
filteredAuditLogs.map((log) => {
sortedAuditLogs.map((log) => {
const badge = getActionBadge(log.action);
const isExpanded = !!expandedAuditLogIds[log.id];
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
@@ -1574,7 +1614,7 @@ export const SettingsPage: React.FC = () => {
setCopiedAuditPayload(false);
}}
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
subtitle="Authoritative event payload from /v1/api/governance/auditlog"
subtitle="Authoritative event payload and activity ledger"
>
{selectedAuditLog && (
<div className="space-y-4 text-xs">
@@ -2111,7 +2151,7 @@ export const SettingsPage: React.FC = () => {
{/* Read-only Engine Notice */}
<div className="p-2.5 bg-blue-500/5 border border-blue-500/20 rounded-xl text-[11px] text-slate-400 leading-relaxed">
<span className="font-semibold text-blue-400">Communication System Report: </span>
Data returned via <span className="font-mono text-slate-300">GET /v1/api/communications/norifications/{selectedNotificationForDetail.organisationId}</span>. Notification status transitions and retry schedules are managed autonomously by gateway microservices.
Notification status transitions and retry schedules are managed autonomously by communication gateway services.
</div>
</div>
@@ -0,0 +1,502 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
MapPin,
Calendar,
User,
Plus,
CheckCircle2,
AlertCircle,
RefreshCw,
Search,
MessageSquare,
Edit2,
FolderKanban,
FileText,
ShieldCheck,
Building,
Send,
} from 'lucide-react';
import {
getProjects,
getProjectSiteVisits,
createSiteVisit,
updateSiteVisit,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { SiteVisit, ApprovalStatus } from '../types';
import {
getApprovalStatusBadge,
formatDate,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
const siteVisitSchema = z.object({
inspectorName: z.string().min(2, 'Inspector name required'),
visitedAt: z.string().min(4, 'Visit date is required'),
findings: z.string().min(5, 'Field findings and observations required'),
status: z.coerce.number().optional(),
});
type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
// Modals
const [isVisitModalOpen, setIsVisitModalOpen] = useState(false);
const [editingVisit, setEditingVisit] = useState<SiteVisit | null>(null);
const [selectedVisitForDetail, setSelectedVisitForDetail] = useState<SiteVisit | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
// Queries
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
const {
data: siteVisits = [],
isLoading: loadingVisits,
refetch: refetchVisits,
isFetching: fetchingVisits,
} = useQuery({
queryKey: ['siteVisits', effectiveProjectId],
queryFn: () => (effectiveProjectId ? getProjectSiteVisits(effectiveProjectId) : Promise.resolve([])),
enabled: !!effectiveProjectId,
});
// Forms
const visitForm = useForm<SiteVisitFormValues>({
resolver: zodResolver(siteVisitSchema),
defaultValues: {
inspectorName: '',
visitedAt: new Date().toISOString().split('T')[0],
findings: '',
status: ApprovalStatus.Pending,
},
});
// Mutations
const createVisitMutation = useMutation({
mutationFn: (vals: SiteVisitFormValues) => {
if (!effectiveProjectId) throw new Error('No project selected');
return createSiteVisit({
projectId: effectiveProjectId,
inspectorName: vals.inspectorName,
findings: vals.findings,
visitedAt: vals.visitedAt,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['siteVisits'] });
setIsVisitModalOpen(false);
visitForm.reset();
success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
},
onError: (err: any) => toastError('Logging Failed', err.message || 'Unable to save site visit.'),
});
const updateVisitMutation = useMutation({
mutationFn: (vals: SiteVisitFormValues) => {
if (!editingVisit) throw new Error('No visit selected');
return updateSiteVisit({
siteVisitId: editingVisit.id,
inspectorName: vals.inspectorName,
findings: vals.findings,
status: vals.status ?? ApprovalStatus.Pending,
visitedAt: vals.visitedAt,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['siteVisits'] });
setIsVisitModalOpen(false);
setEditingVisit(null);
visitForm.reset();
success('Site Visit Updated', 'Inspection report and verification status updated.');
},
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update site visit.'),
});
// Filtered & Sorted Visits
const filteredVisits = useMemo(() => {
return siteVisits.filter((v) => {
return (
!searchTerm.trim() ||
(v.inspectorName && v.inspectorName.toLowerCase().includes(searchTerm.toLowerCase())) ||
(v.findings && v.findings.toLowerCase().includes(searchTerm.toLowerCase()))
);
});
}, [siteVisits, searchTerm]);
const { sortedData: sortedVisits, sortColumn: visitSortCol, sortDirection: visitSortDir, handleSort: handleVisitSort } =
useTableSort(filteredVisits);
// Exports
const visitExportCols: ExportColumn<SiteVisit>[] = [
{ header: 'Inspector Name', key: 'inspectorName' },
{ header: 'Visit Date', accessor: (v) => formatDate(v.visitedAt) },
{ header: 'Findings', key: 'findings' },
{ header: 'Status', accessor: (v) => (v.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
];
const handleOpenEdit = (v: SiteVisit) => {
setEditingVisit(v);
visitForm.reset({
inspectorName: v.inspectorName || '',
visitedAt: v.visitedAt ? v.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0],
findings: v.findings || '',
status: v.status ?? ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
};
return (
<div className="space-y-8 pb-16">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 shadow-lg shadow-emerald-500/10">
<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">
Log physical inspection reports, record field findings, and capture project feedback.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
<button
onClick={() => refetchVisits()}
disabled={fetchingVisits}
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
>
<RefreshCw className={`w-3.5 h-3.5 ${fetchingVisits ? 'animate-spin text-emerald-400' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton data={sortedVisits} columns={visitExportCols} filename="site_visits" title="Site Visits Log" />
<button
onClick={() => {
setEditingVisit(null);
visitForm.reset({
inspectorName: '',
visitedAt: new Date().toISOString().split('T')[0],
findings: '',
status: ApprovalStatus.Pending,
});
setIsVisitModalOpen(true);
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-lg shadow-emerald-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Log Site Visit</span>
</button>
</div>
</div>
{/* KPI Cards */}
<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>
<MapPin className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-white">{siteVisits.length}</p>
<p className="text-[11px] text-slate-400">Field inspections logged</p>
</div>
<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>
<CheckCircle2 className="w-4 h-4 text-teal-400" />
</div>
<p className="text-2xl font-bold text-teal-400">
{siteVisits.filter((v) => v.status === ApprovalStatus.Approved).length}
</p>
<p className="text-[11px] text-slate-400">Verified by project governance</p>
</div>
<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>
<FileText className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-amber-400">
{siteVisits.filter((v) => v.status === ApprovalStatus.Pending || v.status === ApprovalStatus.Review).length}
</p>
<p className="text-[11px] text-slate-400">Awaiting sign-off</p>
</div>
<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>
<Building className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-blue-400">{projects.length}</p>
<p className="text-[11px] text-slate-400">Under physical monitoring</p>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-xs text-slate-400">
<FolderKanban className="w-3.5 h-3.5 text-emerald-400" />
<span>Project:</span>
<select
value={selectedProjectId}
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-emerald-500"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
<input
type="text"
placeholder="Search inspector / findings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 w-48 sm:w-64"
/>
</div>
</div>
</div>
{/* Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
Inspector Name
</SortableTh>
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
Inspection Date
</SortableTh>
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
Field Observations & Findings
</SortableTh>
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
Verification Status
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{loadingVisits ? (
<tr>
<td colSpan={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-emerald-500 border-t-transparent rounded-full animate-spin" />
<span>Loading site visit logs...</span>
</div>
</td>
</tr>
) : sortedVisits.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No site visits recorded for this project yet.
</td>
</tr>
) : (
sortedVisits.map((v) => (
<tr key={v.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span>{v.inspectorName || 'Field Officer'}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">
<div className="flex items-center gap-1.5 text-slate-300">
<Calendar className="w-3 h-3 text-slate-500" />
<span>{formatDate(v.visitedAt)}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
{v.findings || 'No findings recorded.'}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td>
<td className="py-3.5 px-4 text-right space-x-2">
<button
onClick={() => {
setSelectedVisitForDetail(v);
setIsDetailModalOpen(true);
}}
className="px-2.5 py-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/40 rounded-lg border border-emerald-800/40 transition cursor-pointer"
>
View & Feedback
</button>
<button
onClick={() => handleOpenEdit(v)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
title="Edit site visit"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Modal: Create/Edit Site Visit */}
<Modal
isOpen={isVisitModalOpen}
onClose={() => setIsVisitModalOpen(false)}
title={editingVisit ? 'Edit Site Visit Report' : 'Log Project Site Visit'}
size="md"
>
<form
onSubmit={visitForm.handleSubmit((d) => (editingVisit ? updateVisitMutation.mutate(d) : createVisitMutation.mutate(d)))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Inspector / Officer Name *</label>
<input
{...visitForm.register('inspectorName')}
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-emerald-500"
placeholder="e.g. Dr. K. Mokoena (Lead M&E Auditor)"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Inspection Date *</label>
<input
type="date"
{...visitForm.register('visitedAt')}
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-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Field Findings & Observations *</label>
<textarea
rows={4}
{...visitForm.register('findings')}
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-emerald-500"
placeholder="Detail on-site verification, physical progress, equipment installed, and beneficiary feedback..."
/>
</div>
{editingVisit && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
<select
{...visitForm.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-emerald-500"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
<button
type="button"
onClick={() => setIsVisitModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
>
Cancel
</button>
<button
type="submit"
disabled={createVisitMutation.isPending || updateVisitMutation.isPending}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white transition shadow-md shadow-emerald-600/30"
>
{editingVisit ? 'Update Report' : 'Save Site Visit'}
</button>
</div>
</form>
</Modal>
{/* Modal: Detail & Feedback */}
{selectedVisitForDetail && (
<Modal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
title={`Site Visit: ${selectedVisitForDetail.inspectorName || 'Field Inspection'}`}
size="lg"
>
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Inspection Date</span>
<span className="text-xs font-bold text-white">{formatDate(selectedVisitForDetail.visitedAt)}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Status</span>
<div className="mt-0.5">{getApprovalStatusBadge(selectedVisitForDetail.status)}</div>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Inspector</span>
<span className="text-xs font-bold text-emerald-400">{selectedVisitForDetail.inspectorName}</span>
</div>
</div>
<div>
<h4 className="text-xs font-bold text-white uppercase tracking-wider mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-emerald-400" />
<span>Field Findings & Observations</span>
</h4>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 text-xs text-slate-200 whitespace-pre-wrap leading-relaxed">
{selectedVisitForDetail.findings || 'No detailed findings recorded.'}
</div>
</div>
{/* Feedback & Governance Notes Section */}
<div className="space-y-3 pt-4 border-t border-slate-800">
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-400" />
<span>Governance & Remediation Feedback</span>
</h4>
<div className="p-3 bg-blue-950/20 border border-blue-800/40 rounded-xl text-xs text-blue-300">
Site visit report is documented in the central monitoring audit ledger. You can update findings or sign off this
field evaluation using the edit report action.
</div>
</div>
</div>
</Modal>
)}
</div>
);
};