Added, beneficiaries, site visits and change management
This commit is contained in:
@@ -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 “Dispatch Invitation” 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 & Message Payload</th>
|
||||
<th className="py-3.5 px-4">Recipient & 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 & Message Payload
|
||||
</SortableTh>
|
||||
<SortableTh column="recipient" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||
Recipient & 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user