Merge pull request 'Added, beneficiaries, site visits and change management' (#104) from finishline into main

Reviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
2026-09-27 11:42:03 +02:00
16 changed files with 3132 additions and 212 deletions
+8
View File
@@ -10,6 +10,9 @@ const LandingPage = lazy(() => import('./pages/LandingPage').then(m => ({ defaul
const ExecutiveDashboard = lazy(() => import('./pages/ExecutiveDashboard').then(m => ({ default: m.ExecutiveDashboard })));
const GovernancePage = lazy(() => import('./pages/GovernancePage').then(m => ({ default: m.GovernancePage })));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ default: m.ProjectsPage })));
const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage })));
const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage })));
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
@@ -46,6 +49,11 @@ export default function App() {
<Route path="/dashboard" element={<ExecutiveDashboard />} />
<Route path="/governance" element={<GovernancePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/beneficiaries" element={<BeneficiariesPage />} />
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
<Route path="/change-management" element={<ChangeManagementPage />} />
<Route path="/site-visits" element={<SiteVisitsPage />} />
<Route path="/grants" element={<GrantsPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/settings" element={<SettingsPage />} />
@@ -24,6 +24,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import {
Instrument,
Instruction,
@@ -237,6 +238,20 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
});
}, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Sorting hook
const {
sortedData: sortedInstruments,
sortColumn: instSortCol,
sortDirection: instSortDir,
handleSort: handleInstSort,
} = useTableSort<Instrument>(filteredInstruments, null, null, {
code: (inst: Instrument) => inst.code || `INST-${inst.id}`,
name: (inst: Instrument) => inst.name,
programmeId: (inst: Instrument) => programmeMap.get(String(inst.programmeId))?.name || `Programme #${inst.programmeId}`,
status: (inst: Instrument) => getActivityStatusBadge(inst.status).label,
createdAt: (inst: Instrument) => inst.createdAt,
});
// Export columns definition
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
() => [
@@ -400,16 +415,26 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
<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">
<th className="py-3 px-4">Code / Ref</th>
<th className="py-3 px-4">Instrument Title</th>
<th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Created Info</th>
<SortableTh column="code" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
Code / Ref
</SortableTh>
<SortableTh column="name" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
Instrument Title
</SortableTh>
<SortableTh column="programmeId" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
Associated Programme
</SortableTh>
<SortableTh column="status" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
Created Info
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredInstruments.map((inst) => {
{sortedInstruments.map((inst) => {
const statusBadge = getActivityStatusBadge(inst.status);
const parentProg = programmeMap.get(String(inst.programmeId));
@@ -24,6 +24,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import {
Rule,
Programme,
@@ -233,6 +234,20 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
});
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Sorting hook
const {
sortedData: sortedRules,
sortColumn: ruleSortCol,
sortDirection: ruleSortDir,
handleSort: handleRuleSort,
} = useTableSort<Rule>(filteredRules, null, null, {
name: (r: Rule) => r.name,
programmeId: (r: Rule) => programmeMap.get(String(r.programmeId))?.name || `Programme #${r.programmeId}`,
version: (r: Rule) => r.version || 1,
status: (r: Rule) => getActivityStatusBadge(r.status).label,
createdAt: (r: Rule) => r.createdAt,
});
// Export columns definition
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
() => [
@@ -396,16 +411,26 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
<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">
<th className="py-3 px-4">Rule Title & Directives</th>
<th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Version</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Audit Info</th>
<SortableTh column="name" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
Rule Title & Directives
</SortableTh>
<SortableTh column="programmeId" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
Associated Programme
</SortableTh>
<SortableTh column="version" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
Version
</SortableTh>
<SortableTh column="status" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
Audit Info
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredRules.map((r) => {
{sortedRules.map((r) => {
const statusBadge = getActivityStatusBadge(r.status);
const parentProg = programmeMap.get(String(r.programmeId));
@@ -11,10 +11,14 @@ import {
Menu,
X,
ChevronRight,
ChevronDown,
ShieldCheck,
Database,
Home,
User,
Users,
GitPullRequest,
MapPin,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
@@ -23,7 +27,22 @@ import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
const navItems = [
interface SubNavItem {
name: string;
path: string;
icon: React.ElementType;
help?: string;
}
interface NavItem {
name: string;
path: string;
icon: React.ElementType;
help: string;
subItems?: SubNavItem[];
}
const navItems: NavItem[] = [
{
name: 'Executive Dashboard',
path: '/dashboard',
@@ -41,6 +60,26 @@ const navItems = [
path: '/projects',
icon: FolderKanban,
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
subItems: [
{
name: 'Beneficiaries',
path: '/projects/beneficiaries',
icon: Users,
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
},
{
name: 'Change Management',
path: '/projects/change-management',
icon: GitPullRequest,
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
},
],
},
{
name: 'Site Visits',
path: '/site-visits',
icon: MapPin,
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
},
{
name: 'Grant & Financials',
@@ -151,21 +190,52 @@ export const AppLayout: React.FC = () => {
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
{navItems.map((item) => {
const Icon = item.icon;
const hasSub = item.subItems && item.subItems.length > 0;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
<div key={item.path} className="space-y-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
title={item.name}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate flex-1">{item.name}</span>}
</NavLink>
{/* Sub-items under parent item */}
{hasSub && sidebarOpen && (
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
{item.subItems?.map((subItem) => {
const SubIcon = subItem.icon;
return (
<NavLink
key={subItem.path}
to={subItem.path}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[11px] font-medium transition-all min-w-0 ${
isActive
? 'bg-blue-600/20 text-blue-300 font-semibold border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`
}
title={subItem.name}
>
<SubIcon className="w-3.5 h-3.5 shrink-0 text-slate-400" />
<span className="truncate">{subItem.name}</span>
</NavLink>
);
})}
</div>
)}
</div>
);
})}
</nav>
@@ -42,6 +42,7 @@ import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import {
Contract,
Award,
@@ -406,6 +407,30 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{ header: 'Document URI', key: 'fileUri', format: (val) => val || '—' },
];
// Table sorting hook
const {
sortedData: sortedContracts,
sortColumn: contractSortCol,
sortDirection: contractSortDir,
handleSort: handleContractSort,
} = useTableSort<Contract>(filteredContracts, null, null, {
id: (c: any) => c.id,
awardId: (c: any) => c.totalValue || c.awardId,
programmeId: (c: any) => {
const a = getAwardForContract(c);
const pr = getProgrammeForAward(a);
return pr?.name || '';
},
organisationId: (c: any) => {
const a = getAwardForContract(c);
const o = getOrganisationForAward(a);
return o?.name || '';
},
signedDate: (c: any) => c.signedDate,
status: (c: any) => getContractStatusBadge(c.status).label,
fileUri: (c: any) => c.fileUri || '',
});
return (
<div className="space-y-6">
{/* Summary Cards */}
@@ -612,18 +637,32 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-[#151b2c]/80 text-slate-400 border-b border-slate-800/80 uppercase font-semibold text-[11px] tracking-wider">
<tr>
<th className="py-3.5 px-4">Contract / Ref</th>
<th className="py-3.5 px-4">Award & Amount</th>
<th className="py-3.5 px-4">Programme & Project</th>
<th className="py-3.5 px-4">Organisation</th>
<th className="py-3.5 px-4">Validity Period</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Document / S3</th>
<th className="py-3.5 px-4 text-right">Actions</th>
<SortableTh column="id" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Contract / Ref
</SortableTh>
<SortableTh column="awardId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Award & Amount
</SortableTh>
<SortableTh column="programmeId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Programme & Project
</SortableTh>
<SortableTh column="organisationId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Organisation
</SortableTh>
<SortableTh column="signedDate" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Validity Period
</SortableTh>
<SortableTh column="status" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="fileUri" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Document / S3
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredContracts.map((contract) => {
{sortedContracts.map((contract) => {
const award = getAwardForContract(contract);
const org = getOrganisationForAward(award);
const prog = getProgrammeForAward(award);
@@ -233,7 +233,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
if (variables.status === ApprovalStatus.Approved) {
success('Evidence Approved & Verified', 'Approval recorded. Contributes towards contractual milestone sign-off.');
} else if (variables.status === ApprovalStatus.Rejected) {
success('Evidence Rejected', 'Evidence marked as Rejected via verification endpoint.');
success('Evidence Rejected', 'Evidence marked as Rejected by reviewer.');
} else {
success('Status Updated', 'Evidence status updated successfully.');
}
@@ -971,7 +971,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the endpoint action below. Approved evidence validates progress towards milestone completion.
Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the review actions below. Approved evidence validates progress towards milestone completion.
</p>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-1">
@@ -73,7 +73,7 @@ export function ExportButton<T>({
};
return (
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<div className={`relative inline-block ${isOpen ? 'z-[9999]' : 'z-30'} ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => !isDisabled && setIsOpen(!isOpen)}
@@ -97,7 +97,7 @@ export function ExportButton<T>({
</button>
{isOpen && !isDisabled && (
<div className="absolute right-0 mt-1.5 w-52 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95">
<div className="absolute right-0 mt-2 w-56 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-[99999] overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95 ring-1 ring-black/40">
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
@@ -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>
);
};
@@ -0,0 +1,137 @@
import React, { useState, useMemo, useCallback } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
export type SortDirection = 'asc' | 'desc' | null;
export interface SortConfig<T> {
column: string | null;
direction: SortDirection;
customExtractors?: Record<string, (item: T) => any>;
}
export function useTableSort<T>(
data: T[],
initialColumn: string | null = null,
initialDirection: SortDirection = null,
customExtractors?: Record<string, (item: T) => any>
) {
const [sortColumn, setSortColumn] = useState<string | null>(initialColumn);
const [sortDirection, setSortDirection] = useState<SortDirection>(initialDirection);
const handleSort = useCallback((column: string) => {
if (sortColumn !== column) {
setSortColumn(column);
setSortDirection('asc');
} else {
if (sortDirection === 'asc') {
setSortDirection('desc');
} else if (sortDirection === 'desc') {
setSortDirection(null);
setSortColumn(null);
} else {
setSortDirection('asc');
}
}
}, [sortColumn, sortDirection]);
const sortedData = useMemo(() => {
if (!sortColumn || !sortDirection || !Array.isArray(data)) {
return data;
}
const items = [...data];
return items.sort((a: any, b: any) => {
let valA = customExtractors && customExtractors[sortColumn]
? customExtractors[sortColumn](a)
: a?.[sortColumn];
let valB = customExtractors && customExtractors[sortColumn]
? customExtractors[sortColumn](b)
: b?.[sortColumn];
if (valA === undefined || valA === null) valA = '';
if (valB === undefined || valB === null) valB = '';
// Number comparison
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDirection === 'asc' ? valA - valB : valB - valA;
}
// Numeric string comparison
const numA = Number(valA);
const numB = Number(valB);
if (!isNaN(numA) && !isNaN(numB) && typeof valA !== 'boolean' && typeof valB !== 'boolean' && String(valA).trim() !== '' && String(valB).trim() !== '') {
return sortDirection === 'asc' ? numA - numB : numB - numA;
}
// Date comparison if valid ISO / date string
const dateA = Date.parse(String(valA));
const dateB = Date.parse(String(valB));
if (!isNaN(dateA) && !isNaN(dateB) && (String(valA).includes('-') || String(valA).includes('/'))) {
return sortDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
// String comparison
const strA = String(valA).toLowerCase();
const strB = String(valB).toLowerCase();
if (strA < strB) return sortDirection === 'asc' ? -1 : 1;
if (strA > strB) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortColumn, sortDirection, customExtractors]);
return {
sortedData,
sortColumn,
sortDirection,
handleSort,
};
}
export interface SortableThProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
column: string;
currentColumn: string | null;
direction: SortDirection;
onSort: (column: string) => void;
children: React.ReactNode;
align?: 'left' | 'center' | 'right';
}
export const SortableTh: React.FC<SortableThProps> = ({
column,
currentColumn,
direction,
onSort,
children,
align = 'left',
className = '',
...props
}) => {
const isCurrent = currentColumn === column && direction !== null;
return (
<th
{...props}
onClick={() => onSort(column)}
className={`cursor-pointer select-none group transition-colors hover:text-white ${className}`}
title={`Click to sort (Ascending, Descending, Clear)`}
>
<div
className={`inline-flex items-center gap-1 ${
align === 'right' ? 'justify-end w-full' : align === 'center' ? 'justify-center w-full' : 'justify-start'
}`}
>
<span>{children}</span>
{isCurrent && direction === 'asc' && (
<ArrowUp className="w-3 h-3 text-blue-400 shrink-0" />
)}
{isCurrent && direction === 'desc' && (
<ArrowDown className="w-3 h-3 text-blue-400 shrink-0" />
)}
{!isCurrent && (
<ArrowUpDown className="w-3 h-3 text-slate-600 group-hover:text-slate-400 opacity-40 group-hover:opacity-100 shrink-0 transition" />
)}
</div>
</th>
);
};