Merge pull request 'Added, beneficiaries, site visits and change management' (#104) from finishline into main
Reviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
@@ -10,6 +10,9 @@ const LandingPage = lazy(() => import('./pages/LandingPage').then(m => ({ defaul
|
|||||||
const ExecutiveDashboard = lazy(() => import('./pages/ExecutiveDashboard').then(m => ({ default: m.ExecutiveDashboard })));
|
const ExecutiveDashboard = lazy(() => import('./pages/ExecutiveDashboard').then(m => ({ default: m.ExecutiveDashboard })));
|
||||||
const GovernancePage = lazy(() => import('./pages/GovernancePage').then(m => ({ default: m.GovernancePage })));
|
const GovernancePage = lazy(() => import('./pages/GovernancePage').then(m => ({ default: m.GovernancePage })));
|
||||||
const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ default: m.ProjectsPage })));
|
const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ default: m.ProjectsPage })));
|
||||||
|
const BeneficiariesPage = lazy(() => import('./pages/BeneficiariesPage').then(m => ({ default: m.BeneficiariesPage })));
|
||||||
|
const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').then(m => ({ default: m.ChangeManagementPage })));
|
||||||
|
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
|
||||||
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
|
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
|
||||||
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||||
@@ -46,6 +49,11 @@ export default function App() {
|
|||||||
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
||||||
<Route path="/governance" element={<GovernancePage />} />
|
<Route path="/governance" element={<GovernancePage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="/projects/beneficiaries" element={<BeneficiariesPage />} />
|
||||||
|
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
|
||||||
|
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
|
||||||
|
<Route path="/change-management" element={<ChangeManagementPage />} />
|
||||||
|
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
||||||
<Route path="/grants" element={<GrantsPage />} />
|
<Route path="/grants" element={<GrantsPage />} />
|
||||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
|
|||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { ExportColumn } from '../../utils/exportUtils';
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||||
import {
|
import {
|
||||||
Instrument,
|
Instrument,
|
||||||
Instruction,
|
Instruction,
|
||||||
@@ -237,6 +238,20 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
|||||||
});
|
});
|
||||||
}, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
|
}, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
|
||||||
|
|
||||||
|
// Sorting hook
|
||||||
|
const {
|
||||||
|
sortedData: sortedInstruments,
|
||||||
|
sortColumn: instSortCol,
|
||||||
|
sortDirection: instSortDir,
|
||||||
|
handleSort: handleInstSort,
|
||||||
|
} = useTableSort<Instrument>(filteredInstruments, null, null, {
|
||||||
|
code: (inst: Instrument) => inst.code || `INST-${inst.id}`,
|
||||||
|
name: (inst: Instrument) => inst.name,
|
||||||
|
programmeId: (inst: Instrument) => programmeMap.get(String(inst.programmeId))?.name || `Programme #${inst.programmeId}`,
|
||||||
|
status: (inst: Instrument) => getActivityStatusBadge(inst.status).label,
|
||||||
|
createdAt: (inst: Instrument) => inst.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
// Export columns definition
|
// Export columns definition
|
||||||
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
|
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -400,16 +415,26 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
|||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
<th className="py-3 px-4">Code / Ref</th>
|
<SortableTh column="code" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
|
||||||
<th className="py-3 px-4">Instrument Title</th>
|
Code / Ref
|
||||||
<th className="py-3 px-4">Associated Programme</th>
|
</SortableTh>
|
||||||
<th className="py-3 px-4">Status</th>
|
<SortableTh column="name" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
|
||||||
<th className="py-3 px-4">Created Info</th>
|
Instrument Title
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="programmeId" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
|
||||||
|
Associated Programme
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
|
||||||
|
Status
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="createdAt" currentColumn={instSortCol} direction={instSortDir} onSort={handleInstSort} className="py-3 px-4">
|
||||||
|
Created Info
|
||||||
|
</SortableTh>
|
||||||
<th className="py-3 px-4 text-right">Actions</th>
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
|
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
|
||||||
{filteredInstruments.map((inst) => {
|
{sortedInstruments.map((inst) => {
|
||||||
const statusBadge = getActivityStatusBadge(inst.status);
|
const statusBadge = getActivityStatusBadge(inst.status);
|
||||||
const parentProg = programmeMap.get(String(inst.programmeId));
|
const parentProg = programmeMap.get(String(inst.programmeId));
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
|
|||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { ExportColumn } from '../../utils/exportUtils';
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||||
import {
|
import {
|
||||||
Rule,
|
Rule,
|
||||||
Programme,
|
Programme,
|
||||||
@@ -233,6 +234,20 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
});
|
});
|
||||||
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
|
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
|
||||||
|
|
||||||
|
// Sorting hook
|
||||||
|
const {
|
||||||
|
sortedData: sortedRules,
|
||||||
|
sortColumn: ruleSortCol,
|
||||||
|
sortDirection: ruleSortDir,
|
||||||
|
handleSort: handleRuleSort,
|
||||||
|
} = useTableSort<Rule>(filteredRules, null, null, {
|
||||||
|
name: (r: Rule) => r.name,
|
||||||
|
programmeId: (r: Rule) => programmeMap.get(String(r.programmeId))?.name || `Programme #${r.programmeId}`,
|
||||||
|
version: (r: Rule) => r.version || 1,
|
||||||
|
status: (r: Rule) => getActivityStatusBadge(r.status).label,
|
||||||
|
createdAt: (r: Rule) => r.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
// Export columns definition
|
// Export columns definition
|
||||||
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
|
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -396,16 +411,26 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
<th className="py-3 px-4">Rule Title & Directives</th>
|
<SortableTh column="name" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
|
||||||
<th className="py-3 px-4">Associated Programme</th>
|
Rule Title & Directives
|
||||||
<th className="py-3 px-4">Version</th>
|
</SortableTh>
|
||||||
<th className="py-3 px-4">Status</th>
|
<SortableTh column="programmeId" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
|
||||||
<th className="py-3 px-4">Audit Info</th>
|
Associated Programme
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="version" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
|
||||||
|
Version
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
|
||||||
|
Status
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="createdAt" currentColumn={ruleSortCol} direction={ruleSortDir} onSort={handleRuleSort} className="py-3 px-4">
|
||||||
|
Audit Info
|
||||||
|
</SortableTh>
|
||||||
<th className="py-3 px-4 text-right">Actions</th>
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
|
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
|
||||||
{filteredRules.map((r) => {
|
{sortedRules.map((r) => {
|
||||||
const statusBadge = getActivityStatusBadge(r.status);
|
const statusBadge = getActivityStatusBadge(r.status);
|
||||||
const parentProg = programmeMap.get(String(r.programmeId));
|
const parentProg = programmeMap.get(String(r.programmeId));
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Database,
|
Database,
|
||||||
Home,
|
Home,
|
||||||
User,
|
User,
|
||||||
|
Users,
|
||||||
|
GitPullRequest,
|
||||||
|
MapPin,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { BrandLogo } from '../ui/BrandLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
@@ -23,7 +27,22 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { getUsers } from '../../services/api';
|
import { getUsers } from '../../services/api';
|
||||||
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
||||||
|
|
||||||
const navItems = [
|
interface SubNavItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
help?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
help: string;
|
||||||
|
subItems?: SubNavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
name: 'Executive Dashboard',
|
name: 'Executive Dashboard',
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
@@ -41,6 +60,26 @@ const navItems = [
|
|||||||
path: '/projects',
|
path: '/projects',
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
name: 'Beneficiaries',
|
||||||
|
path: '/projects/beneficiaries',
|
||||||
|
icon: Users,
|
||||||
|
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Change Management',
|
||||||
|
path: '/projects/change-management',
|
||||||
|
icon: GitPullRequest,
|
||||||
|
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Site Visits',
|
||||||
|
path: '/site-visits',
|
||||||
|
icon: MapPin,
|
||||||
|
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Grant & Financials',
|
name: 'Grant & Financials',
|
||||||
@@ -151,10 +190,13 @@ export const AppLayout: React.FC = () => {
|
|||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const hasSub = item.subItems && item.subItems.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={item.path} className="space-y-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
end={hasSub}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||||
isActive
|
isActive
|
||||||
@@ -162,10 +204,38 @@ export const AppLayout: React.FC = () => {
|
|||||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
title={item.name}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
{sidebarOpen && <span className="truncate flex-1">{item.name}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* Sub-items under parent item */}
|
||||||
|
{hasSub && sidebarOpen && (
|
||||||
|
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||||
|
{item.subItems?.map((subItem) => {
|
||||||
|
const SubIcon = subItem.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={subItem.path}
|
||||||
|
to={subItem.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[11px] font-medium transition-all min-w-0 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/20 text-blue-300 font-semibold border border-blue-500/30'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
title={subItem.name}
|
||||||
|
>
|
||||||
|
<SubIcon className="w-3.5 h-3.5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">{subItem.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useToast } from '../ui/Toast';
|
|||||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { ExportColumn } from '../../utils/exportUtils';
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
Award,
|
Award,
|
||||||
@@ -406,6 +407,30 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
{ header: 'Document URI', key: 'fileUri', format: (val) => val || '—' },
|
{ header: 'Document URI', key: 'fileUri', format: (val) => val || '—' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Table sorting hook
|
||||||
|
const {
|
||||||
|
sortedData: sortedContracts,
|
||||||
|
sortColumn: contractSortCol,
|
||||||
|
sortDirection: contractSortDir,
|
||||||
|
handleSort: handleContractSort,
|
||||||
|
} = useTableSort<Contract>(filteredContracts, null, null, {
|
||||||
|
id: (c: any) => c.id,
|
||||||
|
awardId: (c: any) => c.totalValue || c.awardId,
|
||||||
|
programmeId: (c: any) => {
|
||||||
|
const a = getAwardForContract(c);
|
||||||
|
const pr = getProgrammeForAward(a);
|
||||||
|
return pr?.name || '';
|
||||||
|
},
|
||||||
|
organisationId: (c: any) => {
|
||||||
|
const a = getAwardForContract(c);
|
||||||
|
const o = getOrganisationForAward(a);
|
||||||
|
return o?.name || '';
|
||||||
|
},
|
||||||
|
signedDate: (c: any) => c.signedDate,
|
||||||
|
status: (c: any) => getContractStatusBadge(c.status).label,
|
||||||
|
fileUri: (c: any) => c.fileUri || '',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
@@ -612,18 +637,32 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
<table className="w-full text-left text-xs text-slate-300">
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
<thead className="bg-[#151b2c]/80 text-slate-400 border-b border-slate-800/80 uppercase font-semibold text-[11px] tracking-wider">
|
<thead className="bg-[#151b2c]/80 text-slate-400 border-b border-slate-800/80 uppercase font-semibold text-[11px] tracking-wider">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-3.5 px-4">Contract / Ref</th>
|
<SortableTh column="id" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Award & Amount</th>
|
Contract / Ref
|
||||||
<th className="py-3.5 px-4">Programme & Project</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Organisation</th>
|
<SortableTh column="awardId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Validity Period</th>
|
Award & Amount
|
||||||
<th className="py-3.5 px-4">Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Document / S3</th>
|
<SortableTh column="programmeId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
Programme & Project
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="organisationId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
|
Organisation
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="signedDate" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
|
Validity Period
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
|
Status
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="fileUri" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
|
||||||
|
Document / S3
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{filteredContracts.map((contract) => {
|
{sortedContracts.map((contract) => {
|
||||||
const award = getAwardForContract(contract);
|
const award = getAwardForContract(contract);
|
||||||
const org = getOrganisationForAward(award);
|
const org = getOrganisationForAward(award);
|
||||||
const prog = getProgrammeForAward(award);
|
const prog = getProgrammeForAward(award);
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
if (variables.status === ApprovalStatus.Approved) {
|
if (variables.status === ApprovalStatus.Approved) {
|
||||||
success('Evidence Approved & Verified', 'Approval recorded. Contributes towards contractual milestone sign-off.');
|
success('Evidence Approved & Verified', 'Approval recorded. Contributes towards contractual milestone sign-off.');
|
||||||
} else if (variables.status === ApprovalStatus.Rejected) {
|
} else if (variables.status === ApprovalStatus.Rejected) {
|
||||||
success('Evidence Rejected', 'Evidence marked as Rejected via verification endpoint.');
|
success('Evidence Rejected', 'Evidence marked as Rejected by reviewer.');
|
||||||
} else {
|
} else {
|
||||||
success('Status Updated', 'Evidence status updated successfully.');
|
success('Status Updated', 'Evidence status updated successfully.');
|
||||||
}
|
}
|
||||||
@@ -971,7 +971,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||||
Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the endpoint action below. Approved evidence validates progress towards milestone completion.
|
Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the review actions below. Approved evidence validates progress towards milestone completion.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-1">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-1">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function ExportButton<T>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
|
<div className={`relative inline-block ${isOpen ? 'z-[9999]' : 'z-30'} ${className}`} ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !isDisabled && setIsOpen(!isOpen)}
|
onClick={() => !isDisabled && setIsOpen(!isOpen)}
|
||||||
@@ -97,7 +97,7 @@ export function ExportButton<T>({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && !isDisabled && (
|
{isOpen && !isDisabled && (
|
||||||
<div className="absolute right-0 mt-1.5 w-52 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95">
|
<div className="absolute right-0 mt-2 w-56 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-[99999] overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95 ring-1 ring-black/40">
|
||||||
<div className="p-2 border-b border-slate-800/80">
|
<div className="p-2 border-b border-slate-800/80">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
||||||
Export Format ({data.length} items)
|
Export Format ({data.length} items)
|
||||||
|
|||||||
@@ -0,0 +1,928 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
CheckSquare,
|
||||||
|
Edit2,
|
||||||
|
FolderKanban,
|
||||||
|
Target,
|
||||||
|
Award,
|
||||||
|
Sparkles,
|
||||||
|
MapPin,
|
||||||
|
FileCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getProjects,
|
||||||
|
getProjectBeneficiaries,
|
||||||
|
createBeneficiary,
|
||||||
|
updateBeneficiary,
|
||||||
|
getBeneficiaryOutcomes,
|
||||||
|
createOutcome,
|
||||||
|
getProjectComplianceItems,
|
||||||
|
createComplianceItem,
|
||||||
|
updateComplianceItem,
|
||||||
|
} from '../services/api';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { useToast } from '../components/ui/Toast';
|
||||||
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
||||||
|
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
||||||
|
|
||||||
|
const beneficiarySchema = z.object({
|
||||||
|
name: z.string().min(2, 'Beneficiary name required'),
|
||||||
|
category: z.string().min(2, 'Category required'),
|
||||||
|
location: z.string().min(2, 'Location required'),
|
||||||
|
targetCount: z.coerce.number().min(1, 'Target count must be at least 1'),
|
||||||
|
reachedCount: z.coerce.number().min(0, 'Reached count must be 0 or more'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const complianceSchema = z.object({
|
||||||
|
title: z.string().min(3, 'Compliance title required'),
|
||||||
|
requirements: z.string().min(5, 'Requirements detail required'),
|
||||||
|
status: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcomeSchema = z.object({
|
||||||
|
name: z.string().min(3, 'Outcome name required'),
|
||||||
|
description: z.string().min(5, 'Description required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BeneficiaryFormValues = z.infer<typeof beneficiarySchema>;
|
||||||
|
type ComplianceFormValues = z.infer<typeof complianceSchema>;
|
||||||
|
type OutcomeFormValues = z.infer<typeof outcomeSchema>;
|
||||||
|
|
||||||
|
export const BeneficiariesPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
|
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [isBenModalOpen, setIsBenModalOpen] = useState(false);
|
||||||
|
const [editingBen, setEditingBen] = useState<Beneficiary | null>(null);
|
||||||
|
|
||||||
|
const [isCompModalOpen, setIsCompModalOpen] = useState(false);
|
||||||
|
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
|
||||||
|
|
||||||
|
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isOutcomeModalOpen, setIsOutcomeModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query beneficiaries
|
||||||
|
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: beneficiaries = [],
|
||||||
|
isLoading: loadingBens,
|
||||||
|
refetch: refetchBens,
|
||||||
|
isFetching: fetchingBens,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['beneficiaries', effectiveProjectId],
|
||||||
|
queryFn: () => (effectiveProjectId ? getProjectBeneficiaries(effectiveProjectId) : Promise.resolve([])),
|
||||||
|
enabled: !!effectiveProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query compliance items
|
||||||
|
const {
|
||||||
|
data: complianceItems = [],
|
||||||
|
isLoading: loadingComps,
|
||||||
|
refetch: refetchComps,
|
||||||
|
isFetching: fetchingComps,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['complianceItems', effectiveProjectId],
|
||||||
|
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])),
|
||||||
|
enabled: !!effectiveProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query outcomes for selected beneficiary
|
||||||
|
const { data: outcomes = [], refetch: refetchOutcomes } = useQuery({
|
||||||
|
queryKey: ['outcomes', selectedBenForDetail?.id],
|
||||||
|
queryFn: () => (selectedBenForDetail?.id ? getBeneficiaryOutcomes(selectedBenForDetail.id) : Promise.resolve([])),
|
||||||
|
enabled: !!selectedBenForDetail?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const benForm = useForm<BeneficiaryFormValues>({
|
||||||
|
resolver: zodResolver(beneficiarySchema),
|
||||||
|
defaultValues: { name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const compForm = useForm<ComplianceFormValues>({
|
||||||
|
resolver: zodResolver(complianceSchema),
|
||||||
|
defaultValues: { title: '', requirements: '', status: ApprovalStatus.Pending },
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcomeForm = useForm<OutcomeFormValues>({
|
||||||
|
resolver: zodResolver(outcomeSchema),
|
||||||
|
defaultValues: { name: '', description: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createBenMutation = useMutation({
|
||||||
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
||||||
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
|
return createBeneficiary({ ...vals, projectId: effectiveProjectId });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
||||||
|
setIsBenModalOpen(false);
|
||||||
|
benForm.reset();
|
||||||
|
success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Registration Failed', err.message || 'Unable to save beneficiary.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBenMutation = useMutation({
|
||||||
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
||||||
|
if (!editingBen) throw new Error('No beneficiary selected');
|
||||||
|
return updateBeneficiary({ ...vals, beneficiaryId: editingBen.id });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
||||||
|
setIsBenModalOpen(false);
|
||||||
|
setEditingBen(null);
|
||||||
|
benForm.reset();
|
||||||
|
success('Beneficiary Updated', 'Beneficiary details and targets updated.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update beneficiary.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCompMutation = useMutation({
|
||||||
|
mutationFn: (vals: ComplianceFormValues) => {
|
||||||
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
|
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
|
||||||
|
setIsCompModalOpen(false);
|
||||||
|
compForm.reset();
|
||||||
|
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to record compliance item.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCompMutation = useMutation({
|
||||||
|
mutationFn: (vals: ComplianceFormValues) => {
|
||||||
|
if (!editingComp) throw new Error('No item selected');
|
||||||
|
return updateComplianceItem({
|
||||||
|
complianceItemId: editingComp.id,
|
||||||
|
title: vals.title,
|
||||||
|
requirements: vals.requirements,
|
||||||
|
status: vals.status ?? ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
|
||||||
|
setIsCompModalOpen(false);
|
||||||
|
setEditingComp(null);
|
||||||
|
compForm.reset();
|
||||||
|
success('Compliance Item Updated', 'Status and requirement record updated.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update compliance item.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOutcomeMutation = useMutation({
|
||||||
|
mutationFn: (vals: OutcomeFormValues) => {
|
||||||
|
if (!selectedBenForDetail) throw new Error('No beneficiary selected');
|
||||||
|
return createOutcome({ beneficiaryId: selectedBenForDetail.id, name: vals.name, description: vals.description });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchOutcomes();
|
||||||
|
setIsOutcomeModalOpen(false);
|
||||||
|
outcomeForm.reset();
|
||||||
|
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Failed to Record Outcome', err.message || 'Unable to save outcome.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered & Sorted Beneficiaries
|
||||||
|
const filteredBens = useMemo(() => {
|
||||||
|
return beneficiaries.filter((b) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
(b.name && b.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(b.location && b.location.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
const matchesCat = categoryFilter === 'all' || b.category === categoryFilter;
|
||||||
|
return matchesSearch && matchesCat;
|
||||||
|
});
|
||||||
|
}, [beneficiaries, searchTerm, categoryFilter]);
|
||||||
|
|
||||||
|
const { sortedData: sortedBens, sortColumn: benSortCol, sortDirection: benSortDir, handleSort: handleBenSort } =
|
||||||
|
useTableSort(filteredBens);
|
||||||
|
|
||||||
|
// Filtered & Sorted Compliance Items
|
||||||
|
const filteredComps = useMemo(() => {
|
||||||
|
return complianceItems.filter((c) => {
|
||||||
|
return (
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
(c.title && c.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(c.requirements && c.requirements.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [complianceItems, searchTerm]);
|
||||||
|
|
||||||
|
const { sortedData: sortedComps, sortColumn: compSortCol, sortDirection: compSortDir, handleSort: handleCompSort } =
|
||||||
|
useTableSort(filteredComps);
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const totalTarget = beneficiaries.reduce((acc, b) => acc + (b.targetCount || 0), 0);
|
||||||
|
const totalReached = beneficiaries.reduce((acc, b) => acc + (b.reachedCount || 0), 0);
|
||||||
|
const reachPercentage = totalTarget > 0 ? Math.min(100, Math.round((totalReached / totalTarget) * 100)) : 0;
|
||||||
|
const verifiedCompCount = complianceItems.filter((c) => c.status === ApprovalStatus.Approved).length;
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
const benExportColumns: ExportColumn<Beneficiary>[] = [
|
||||||
|
{ header: 'Beneficiary Name', key: 'name' },
|
||||||
|
{ header: 'Category', key: 'category' },
|
||||||
|
{ header: 'Location', key: 'location' },
|
||||||
|
{ header: 'Target Population', key: 'targetCount' },
|
||||||
|
{ header: 'Reached Population', key: 'reachedCount' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const compExportColumns: ExportColumn<ComplianceItem>[] = [
|
||||||
|
{ header: 'Compliance Requirement', key: 'title' },
|
||||||
|
{ header: 'Requirements Detail', key: 'requirements' },
|
||||||
|
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleOpenEditBen = (b: Beneficiary) => {
|
||||||
|
setEditingBen(b);
|
||||||
|
benForm.reset({
|
||||||
|
name: b.name || '',
|
||||||
|
category: b.category || 'General',
|
||||||
|
location: b.location || '',
|
||||||
|
targetCount: b.targetCount || 0,
|
||||||
|
reachedCount: b.reachedCount || 0,
|
||||||
|
});
|
||||||
|
setIsBenModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditComp = (c: ComplianceItem) => {
|
||||||
|
setEditingComp(c);
|
||||||
|
compForm.reset({
|
||||||
|
title: c.title || '',
|
||||||
|
requirements: c.requirements || '',
|
||||||
|
status: c.status ?? ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
setIsCompModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-16">
|
||||||
|
{/* Header Banner */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="space-y-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
|
<Users className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
|
Beneficiaries & Compliance Register
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
|
Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
refetchBens();
|
||||||
|
refetchComps();
|
||||||
|
}}
|
||||||
|
disabled={fetchingBens || fetchingComps}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${fetchingBens || fetchingComps ? 'animate-spin text-blue-400' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeTab === 'beneficiaries' ? (
|
||||||
|
<ExportButton
|
||||||
|
data={sortedBens}
|
||||||
|
columns={benExportColumns}
|
||||||
|
filename="project_beneficiaries"
|
||||||
|
title="Beneficiaries Register"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExportButton
|
||||||
|
data={sortedComps}
|
||||||
|
columns={compExportColumns}
|
||||||
|
filename="compliance_items"
|
||||||
|
title="Compliance Requirements"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingBen(null);
|
||||||
|
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
||||||
|
setIsBenModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Register Beneficiary</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingComp(null);
|
||||||
|
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
||||||
|
setIsCompModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-slate-800/90 text-white hover:bg-slate-700/80 border border-slate-700 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Add Compliance Item</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
|
||||||
|
<Users className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Registered target stakeholder groups</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
|
||||||
|
<Target className="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Planned participant headcount</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
|
||||||
|
<Award className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">{totalReached.toLocaleString()}</p>
|
||||||
|
<span className="text-xs font-bold text-slate-400">({reachPercentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${reachPercentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
|
||||||
|
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold text-cyan-400">{complianceItems.length}</p>
|
||||||
|
<span className="text-xs font-medium text-slate-400">({verifiedCompCount} Satisfied)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">Statutory & audit verification records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs & Filters */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('beneficiaries')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||||
|
activeTab === 'beneficiaries'
|
||||||
|
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Beneficiaries List ({beneficiaries.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('compliance')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||||
|
activeTab === 'compliance'
|
||||||
|
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-3.5 h-3.5" />
|
||||||
|
<span>Compliance Items ({complianceItems.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<FolderKanban className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Project:</span>
|
||||||
|
<select
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 w-40 sm:w-56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Beneficiaries Table */}
|
||||||
|
{activeTab === 'beneficiaries' && (
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
|
||||||
|
Beneficiary / Cohort
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="category" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
|
||||||
|
Category
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="location" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
|
||||||
|
Location
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="targetCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
|
||||||
|
Target Headcount
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="reachedCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
|
||||||
|
Reached Headcount
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4">Reach Progress</th>
|
||||||
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
|
{loadingBens ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading beneficiaries...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedBens.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-12 text-center text-slate-400">
|
||||||
|
No beneficiaries recorded for this project.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedBens.map((b) => {
|
||||||
|
const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<tr key={b.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
|
<span>{b.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300">
|
||||||
|
<span className="bg-slate-800/80 px-2 py-0.5 rounded-md border border-slate-700/60 text-[11px]">
|
||||||
|
{b.category || 'General'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300">
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<MapPin className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{b.location || 'Not Specified'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-medium text-slate-200">{(b.targetCount || 0).toLocaleString()}</td>
|
||||||
|
<td className="py-3.5 px-4 font-bold text-emerald-400">{(b.reachedCount || 0).toLocaleString()}</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-2 max-w-[140px]">
|
||||||
|
<div className="flex-1 bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-emerald-500 h-full rounded-full" style={{ width: `${Math.min(100, prog)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-slate-400">{prog}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBenForDetail(b);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-blue-400 hover:text-blue-300 bg-blue-950/40 hover:bg-blue-900/40 rounded-lg border border-blue-800/40 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Compliance & Outcomes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditBen(b)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit beneficiary"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Compliance Items Table */}
|
||||||
|
{activeTab === 'compliance' && (
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
|
||||||
|
Compliance Title
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
|
||||||
|
Requirements
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
|
||||||
|
Verification Status
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="createdAt" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
|
||||||
|
Created
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
|
{loadingComps ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading compliance items...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedComps.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
No compliance items recorded for this project.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedComps.map((c) => (
|
||||||
|
<tr key={c.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
<span>{c.title}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
|
||||||
|
{c.requirements || 'None'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">{getApprovalStatusBadge(c.status)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-400">{formatDate(c.createdAt)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditComp(c)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit compliance item"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Create/Edit Beneficiary */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isBenModalOpen}
|
||||||
|
onClose={() => setIsBenModalOpen(false)}
|
||||||
|
title={editingBen ? 'Edit Beneficiary Profile' : 'Register Project Beneficiary'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={benForm.handleSubmit((d) => (editingBen ? updateBenMutation.mutate(d) : createBenMutation.mutate(d)))}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Beneficiary Name / Group *</label>
|
||||||
|
<input
|
||||||
|
{...benForm.register('name')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="e.g. Rural Female Farmers Cohort"
|
||||||
|
/>
|
||||||
|
{benForm.formState.errors.name && (
|
||||||
|
<span className="text-[11px] text-rose-400 mt-1 block">{benForm.formState.errors.name.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Category *</label>
|
||||||
|
<input
|
||||||
|
{...benForm.register('category')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="e.g. Agriculture, Youth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Location *</label>
|
||||||
|
<input
|
||||||
|
{...benForm.register('location')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="e.g. Limpopo, District 4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Count *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...benForm.register('targetCount')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Reached Count</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...benForm.register('reachedCount')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBenModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createBenMutation.isPending || updateBenMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
|
||||||
|
>
|
||||||
|
{editingBen ? 'Update Beneficiary' : 'Save Beneficiary'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Create/Edit Compliance Item */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isCompModalOpen}
|
||||||
|
onClose={() => setIsCompModalOpen(false)}
|
||||||
|
title={editingComp ? 'Edit Compliance Requirement' : 'Add Compliance Requirement'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
|
||||||
|
<input
|
||||||
|
{...compForm.register('title')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="e.g. Identity Verification & Consent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Requirements Detail *</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
{...compForm.register('requirements')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="Specify requirements and criteria required to satisfy this compliance item..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingComp && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
||||||
|
<select
|
||||||
|
{...compForm.register('status')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCompModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createCompMutation.isPending || updateCompMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
|
||||||
|
>
|
||||||
|
{editingComp ? 'Update Item' : 'Create Item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Beneficiary Detail & Compliance / Outcomes Inspector */}
|
||||||
|
{selectedBenForDetail && (
|
||||||
|
<Modal
|
||||||
|
isOpen={isDetailModalOpen}
|
||||||
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
|
title={`Beneficiary: ${selectedBenForDetail.name}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Category</span>
|
||||||
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.category || 'General'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Location</span>
|
||||||
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.location || 'National'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Target Count</span>
|
||||||
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.targetCount}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Reached</span>
|
||||||
|
<span className="text-xs font-bold text-emerald-400">{selectedBenForDetail.reachedCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance Satisfied Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<FileCheck className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Compliance Requirements Satisfying this Beneficiary</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{complianceItems.length === 0 ? (
|
||||||
|
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
|
||||||
|
No compliance items recorded for this project yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{complianceItems.map((ci) => (
|
||||||
|
<div
|
||||||
|
key={ci.id}
|
||||||
|
className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between gap-3 text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{ci.title}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate max-w-sm">{ci.requirements}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{getApprovalStatusBadge(ci.status)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcomes Section */}
|
||||||
|
<div className="space-y-3 pt-4 border-t border-slate-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-indigo-400" />
|
||||||
|
<span>Recorded Outcomes & Impact ({outcomes.length})</span>
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOutcomeModalOpen(true)}
|
||||||
|
className="px-3 py-1 bg-indigo-600/30 hover:bg-indigo-600/50 text-indigo-300 border border-indigo-500/40 rounded-lg text-xs font-semibold transition cursor-pointer"
|
||||||
|
>
|
||||||
|
+ Add Outcome
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{outcomes.length === 0 ? (
|
||||||
|
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
|
||||||
|
No outcomes registered for this beneficiary yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{outcomes.map((o) => (
|
||||||
|
<div key={o.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 text-xs">
|
||||||
|
<p className="font-semibold text-white">{o.name}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">{o.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal: Add Outcome */}
|
||||||
|
<Modal isOpen={isOutcomeModalOpen} onClose={() => setIsOutcomeModalOpen(false)} title="Record Beneficiary Outcome" size="md">
|
||||||
|
<form onSubmit={outcomeForm.handleSubmit((d) => createOutcomeMutation.mutate(d))} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Outcome Name *</label>
|
||||||
|
<input
|
||||||
|
{...outcomeForm.register('name')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="e.g. 85% Post-Training Employment Rate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Impact Description *</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
{...outcomeForm.register('description')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="Detail quantitative or qualitative impact observed..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOutcomeModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createOutcomeMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-indigo-600 hover:bg-indigo-500 text-white transition shadow-md shadow-indigo-600/30"
|
||||||
|
>
|
||||||
|
Save Outcome
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
GitPullRequest,
|
||||||
|
AlertOctagon,
|
||||||
|
Plus,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Check,
|
||||||
|
XCircle,
|
||||||
|
Edit2,
|
||||||
|
FolderKanban,
|
||||||
|
Coins,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getProjects,
|
||||||
|
getProjectIssues,
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
getProjectChangeRequests,
|
||||||
|
createChangeRequest,
|
||||||
|
updateChangeRequest,
|
||||||
|
getUsers,
|
||||||
|
} from '../services/api';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { useToast } from '../components/ui/Toast';
|
||||||
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
|
||||||
|
import {
|
||||||
|
getApprovalStatusBadge,
|
||||||
|
getPriorityBadge,
|
||||||
|
formatDate,
|
||||||
|
formatZAR,
|
||||||
|
getUserEmail,
|
||||||
|
PRIORITY_OPTIONS,
|
||||||
|
} from '../lib/formatters';
|
||||||
|
|
||||||
|
const issueSchema = z.object({
|
||||||
|
title: z.string().min(3, 'Issue title required'),
|
||||||
|
description: z.string().min(5, 'Description required'),
|
||||||
|
priority: z.coerce.number().min(1).max(4),
|
||||||
|
assignedTo: z.string().min(1, 'Assignee is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveIssueSchema = z.object({
|
||||||
|
resolution: z.string().min(5, 'Resolution notes required'),
|
||||||
|
status: z.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeRequestSchema = z.object({
|
||||||
|
title: z.string().min(3, 'Title required'),
|
||||||
|
justification: z.string().min(5, 'Justification required'),
|
||||||
|
revisedBudget: z.coerce.number().min(0, 'Revised budget must be non-negative'),
|
||||||
|
revisionDate: z.string().min(4, 'Revision date required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveChangeSchema = z.object({
|
||||||
|
status: z.coerce.number(),
|
||||||
|
justification: z.string().min(3, 'Decision justification required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type IssueFormValues = z.infer<typeof issueSchema>;
|
||||||
|
type ResolveIssueFormValues = z.infer<typeof resolveIssueSchema>;
|
||||||
|
type ChangeRequestFormValues = z.infer<typeof changeRequestSchema>;
|
||||||
|
type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
|
||||||
|
|
||||||
|
export const ChangeManagementPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
|
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
||||||
|
const [editingIssue, setEditingIssue] = useState<Issue | null>(null);
|
||||||
|
|
||||||
|
const [isResolveIssueModalOpen, setIsResolveIssueModalOpen] = useState(false);
|
||||||
|
const [resolvingIssue, setResolvingIssue] = useState<Issue | null>(null);
|
||||||
|
|
||||||
|
const [isChangeModalOpen, setIsChangeModalOpen] = useState(false);
|
||||||
|
const [editingChange, setEditingChange] = useState<ChangeRequest | null>(null);
|
||||||
|
|
||||||
|
const [isResolveChangeModalOpen, setIsResolveChangeModalOpen] = useState(false);
|
||||||
|
const [resolvingChange, setResolvingChange] = useState<ChangeRequest | null>(null);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users-directory'],
|
||||||
|
queryFn: () => getUsers(1, 200),
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: issues = [],
|
||||||
|
isLoading: loadingIssues,
|
||||||
|
refetch: refetchIssues,
|
||||||
|
isFetching: fetchingIssues,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['issues', effectiveProjectId],
|
||||||
|
queryFn: () => (effectiveProjectId ? getProjectIssues(effectiveProjectId) : Promise.resolve([])),
|
||||||
|
enabled: !!effectiveProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: changeRequests = [],
|
||||||
|
isLoading: loadingChanges,
|
||||||
|
refetch: refetchChanges,
|
||||||
|
isFetching: fetchingChanges,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['changeRequests', effectiveProjectId],
|
||||||
|
queryFn: () => (effectiveProjectId ? getProjectChangeRequests(effectiveProjectId) : Promise.resolve([])),
|
||||||
|
enabled: !!effectiveProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const issueForm = useForm<IssueFormValues>({
|
||||||
|
resolver: zodResolver(issueSchema),
|
||||||
|
defaultValues: { title: '', description: '', priority: Priority.Medium, assignedTo: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveIssueForm = useForm<ResolveIssueFormValues>({
|
||||||
|
resolver: zodResolver(resolveIssueSchema),
|
||||||
|
defaultValues: { resolution: '', status: ActivityStatus.Inactive },
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeForm = useForm<ChangeRequestFormValues>({
|
||||||
|
resolver: zodResolver(changeRequestSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
justification: '',
|
||||||
|
revisedBudget: 0,
|
||||||
|
revisionDate: new Date().toISOString().split('T')[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveChangeForm = useForm<ResolveChangeFormValues>({
|
||||||
|
resolver: zodResolver(resolveChangeSchema),
|
||||||
|
defaultValues: { status: ApprovalStatus.Approved, justification: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createIssueMutation = useMutation({
|
||||||
|
mutationFn: (vals: IssueFormValues) => {
|
||||||
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
|
return createIssue({
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
title: vals.title,
|
||||||
|
description: vals.description,
|
||||||
|
priority: vals.priority,
|
||||||
|
assignedTo: vals.assignedTo,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
setIsIssueModalOpen(false);
|
||||||
|
issueForm.reset();
|
||||||
|
success('Issue Logged', 'New project issue created and assigned.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to log issue.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateIssueMutation = useMutation({
|
||||||
|
mutationFn: (vals: IssueFormValues) => {
|
||||||
|
if (!editingIssue) throw new Error('No issue selected');
|
||||||
|
return updateIssue({
|
||||||
|
issueId: editingIssue.id,
|
||||||
|
title: vals.title,
|
||||||
|
description: vals.description,
|
||||||
|
priority: vals.priority,
|
||||||
|
assignedTo: vals.assignedTo,
|
||||||
|
resolution: editingIssue.resolution || '',
|
||||||
|
resolvedAt: editingIssue.resolvedAt || null,
|
||||||
|
status: editingIssue.status || ActivityStatus.Active,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
setIsIssueModalOpen(false);
|
||||||
|
setEditingIssue(null);
|
||||||
|
issueForm.reset();
|
||||||
|
success('Issue Updated', 'Issue record saved.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveIssueMutation = useMutation({
|
||||||
|
mutationFn: (vals: ResolveIssueFormValues) => {
|
||||||
|
if (!resolvingIssue) throw new Error('No issue selected');
|
||||||
|
return updateIssue({
|
||||||
|
issueId: resolvingIssue.id,
|
||||||
|
title: resolvingIssue.title || '',
|
||||||
|
description: resolvingIssue.description || '',
|
||||||
|
priority: resolvingIssue.priority || Priority.Medium,
|
||||||
|
assignedTo: resolvingIssue.assignedTo || '',
|
||||||
|
resolution: vals.resolution,
|
||||||
|
resolvedAt: new Date().toISOString(),
|
||||||
|
status: vals.status,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
setIsResolveIssueModalOpen(false);
|
||||||
|
setResolvingIssue(null);
|
||||||
|
resolveIssueForm.reset();
|
||||||
|
success('Issue Resolved', 'Resolution recorded and issue status updated.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Resolution Failed', err.message || 'Unable to resolve issue.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createChangeMutation = useMutation({
|
||||||
|
mutationFn: (vals: ChangeRequestFormValues) => {
|
||||||
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
|
return createChangeRequest({
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
title: vals.title,
|
||||||
|
justification: vals.justification,
|
||||||
|
revisedBudget: vals.revisedBudget,
|
||||||
|
revisionDate: vals.revisionDate,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['changeRequests'] });
|
||||||
|
setIsChangeModalOpen(false);
|
||||||
|
changeForm.reset();
|
||||||
|
success('Change Request Submitted', 'Formal change request submitted for review.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Submission Failed', err.message || 'Unable to submit change request.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveChangeMutation = useMutation({
|
||||||
|
mutationFn: (vals: ResolveChangeFormValues) => {
|
||||||
|
if (!resolvingChange) throw new Error('No change request selected');
|
||||||
|
return updateChangeRequest({
|
||||||
|
changeRequestId: resolvingChange.id,
|
||||||
|
title: resolvingChange.title || '',
|
||||||
|
justification: vals.justification || resolvingChange.justification || '',
|
||||||
|
revisedBudget: resolvingChange.revisedBudget || 0,
|
||||||
|
status: vals.status,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['changeRequests'] });
|
||||||
|
setIsResolveChangeModalOpen(false);
|
||||||
|
setResolvingChange(null);
|
||||||
|
resolveChangeForm.reset();
|
||||||
|
success('Change Decision Applied', 'Change request decision successfully resolved.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered & Sorted Issues
|
||||||
|
const filteredIssues = useMemo(() => {
|
||||||
|
return issues.filter((iss) => {
|
||||||
|
const matchSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
(iss.title && iss.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(iss.description && iss.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
const matchPrio = priorityFilter === 'all' || String(iss.priority) === String(priorityFilter);
|
||||||
|
return matchSearch && matchPrio;
|
||||||
|
});
|
||||||
|
}, [issues, searchTerm, priorityFilter]);
|
||||||
|
|
||||||
|
const { sortedData: sortedIssues, sortColumn: issueSortCol, sortDirection: issueSortDir, handleSort: handleIssueSort } =
|
||||||
|
useTableSort(filteredIssues);
|
||||||
|
|
||||||
|
// Filtered & Sorted Change Requests
|
||||||
|
const filteredChanges = useMemo(() => {
|
||||||
|
return changeRequests.filter((cr) => {
|
||||||
|
return (
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
(cr.title && cr.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(cr.justification && cr.justification.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [changeRequests, searchTerm]);
|
||||||
|
|
||||||
|
const { sortedData: sortedChanges, sortColumn: changeSortCol, sortDirection: changeSortDir, handleSort: handleChangeSort } =
|
||||||
|
useTableSort(filteredChanges);
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
const issueExportCols: ExportColumn<Issue>[] = [
|
||||||
|
{ header: 'Title', key: 'title' },
|
||||||
|
{ header: 'Description', key: 'description' },
|
||||||
|
{ header: 'Priority', accessor: (i) => (i.priority === Priority.Critical ? 'Critical' : 'Standard') },
|
||||||
|
{ header: 'Assigned To', accessor: (i) => (i.assignedTo ? getUserEmail(i.assignedTo) : 'Unassigned') },
|
||||||
|
{ header: 'Status', accessor: (i) => (i.status === ActivityStatus.Active ? 'Active' : 'Resolved') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const changeExportCols: ExportColumn<ChangeRequest>[] = [
|
||||||
|
{ header: 'Change Title', key: 'title' },
|
||||||
|
{ header: 'Justification', key: 'justification' },
|
||||||
|
{ header: 'Revised Budget', accessor: (c) => formatZAR(c.revisedBudget || 0) },
|
||||||
|
{ header: 'Status', accessor: (c) => (c.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const openResolveIssue = (iss: Issue) => {
|
||||||
|
setResolvingIssue(iss);
|
||||||
|
resolveIssueForm.reset({ resolution: iss.resolution || '', status: ActivityStatus.Inactive });
|
||||||
|
setIsResolveIssueModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openResolveChange = (cr: ChangeRequest, statusToApply: ApprovalStatus) => {
|
||||||
|
setResolvingChange(cr);
|
||||||
|
resolveChangeForm.reset({
|
||||||
|
status: statusToApply,
|
||||||
|
justification: `Approved by Project Governance for execution.`,
|
||||||
|
});
|
||||||
|
setIsResolveChangeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-16">
|
||||||
|
{/* Header Banner */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-amber-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="space-y-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-amber-600/20 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-lg shadow-amber-500/10">
|
||||||
|
<GitPullRequest className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
|
Change Management & Issue Resolution
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
|
Track project execution impediments, review contractual variations, and resolve change requests in-place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
refetchIssues();
|
||||||
|
refetchChanges();
|
||||||
|
}}
|
||||||
|
disabled={fetchingIssues || fetchingChanges}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${fetchingIssues || fetchingChanges ? 'animate-spin text-amber-400' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeTab === 'issues' ? (
|
||||||
|
<ExportButton data={sortedIssues} columns={issueExportCols} filename="project_issues" title="Project Issues" />
|
||||||
|
) : (
|
||||||
|
<ExportButton data={sortedChanges} columns={changeExportCols} filename="change_requests" title="Change Requests" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingIssue(null);
|
||||||
|
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||||
|
setIsIssueModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-lg shadow-amber-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Log Issue</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingChange(null);
|
||||||
|
changeForm.reset({
|
||||||
|
title: '',
|
||||||
|
justification: '',
|
||||||
|
revisedBudget: 0,
|
||||||
|
revisionDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
setIsChangeModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-slate-800/90 text-white hover:bg-slate-700/80 border border-slate-700 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<GitPullRequest className="w-4 h-4 text-blue-400" />
|
||||||
|
<span>Request Change</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Active Issues</span>
|
||||||
|
<AlertOctagon className="w-4 h-4 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-rose-400">{issues.filter((i) => i.status !== ActivityStatus.Inactive).length}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Open project impediments</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Resolved Issues</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">
|
||||||
|
{issues.filter((i) => i.status === ActivityStatus.Inactive).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Closed with documented resolution</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Pending Changes</span>
|
||||||
|
<GitPullRequest className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-amber-400">
|
||||||
|
{changeRequests.filter((c) => c.status === ApprovalStatus.Pending || c.status === ApprovalStatus.Review).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Awaiting governance review</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Approved Variations</span>
|
||||||
|
<Coins className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-blue-400">
|
||||||
|
{changeRequests.filter((c) => c.status === ApprovalStatus.Approved).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Contract amendments enacted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs & Filters */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('issues')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||||
|
activeTab === 'issues'
|
||||||
|
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertOctagon className="w-3.5 h-3.5" />
|
||||||
|
<span>Project Issues ({issues.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('changes')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||||
|
activeTab === 'changes'
|
||||||
|
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitPullRequest className="w-3.5 h-3.5" />
|
||||||
|
<span>Change Requests ({changeRequests.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<FolderKanban className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>Project:</span>
|
||||||
|
<select
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-amber-500 w-40 sm:w-56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Issues Table */}
|
||||||
|
{activeTab === 'issues' && (
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="title" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
|
||||||
|
Issue Summary
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="priority" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
|
||||||
|
Priority
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="assignedTo" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
|
||||||
|
Assigned To
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
|
||||||
|
Status
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4">Resolution Notes</th>
|
||||||
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
|
{loadingIssues ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading project issues...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedIssues.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-400">
|
||||||
|
No issues recorded for this project.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedIssues.map((iss) => {
|
||||||
|
const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt;
|
||||||
|
return (
|
||||||
|
<tr key={iss.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<AlertOctagon
|
||||||
|
className={`w-4 h-4 mt-0.5 shrink-0 ${isResolved ? 'text-emerald-400' : 'text-rose-400'}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{iss.title}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 font-normal line-clamp-1">{iss.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">{getPriorityBadge(iss.priority)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span className="truncate max-w-[150px]">{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
{isResolved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Resolved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||||
|
<Clock className="w-3 h-3" /> Open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-400 max-w-xs truncate">
|
||||||
|
{iss.resolution || <span className="italic text-slate-600">Pending resolution...</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openResolveIssue(iss)}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-amber-400 hover:text-amber-300 bg-amber-950/40 hover:bg-amber-900/40 rounded-lg border border-amber-800/40 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingIssue(iss);
|
||||||
|
issueForm.reset({
|
||||||
|
title: iss.title || '',
|
||||||
|
description: iss.description || '',
|
||||||
|
priority: iss.priority || Priority.Medium,
|
||||||
|
assignedTo: iss.assignedTo || '',
|
||||||
|
});
|
||||||
|
setIsIssueModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit issue"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Change Requests Table */}
|
||||||
|
{activeTab === 'changes' && (
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="title" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
|
||||||
|
Change Proposal
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="justification" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
|
||||||
|
Justification
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="revisedBudget" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
|
||||||
|
Revised Budget
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
|
||||||
|
Governance Status
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4 text-right">In-Place Decision</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
|
{loadingChanges ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading change requests...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedChanges.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
No change requests logged for this project.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedChanges.map((cr) => (
|
||||||
|
<tr key={cr.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitPullRequest className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
<span>{cr.title}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={cr.justification || ''}>
|
||||||
|
{cr.justification || 'None'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono font-semibold text-slate-200">
|
||||||
|
{formatZAR(cr.revisedBudget || 0)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">{getApprovalStatusBadge(cr.status)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right space-x-2">
|
||||||
|
{cr.status === ApprovalStatus.Approved ? (
|
||||||
|
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
||||||
|
Resolved & Enacted
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||||
|
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" /> Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||||
|
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3 h-3" /> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Create/Edit Issue */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isIssueModalOpen}
|
||||||
|
onClose={() => setIsIssueModalOpen(false)}
|
||||||
|
title={editingIssue ? 'Edit Project Issue' : 'Log Project Issue'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={issueForm.handleSubmit((d) => (editingIssue ? updateIssueMutation.mutate(d) : createIssueMutation.mutate(d)))}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Issue Title *</label>
|
||||||
|
<input
|
||||||
|
{...issueForm.register('title')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="e.g. Procurement delays for regional solar supplies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
{...issueForm.register('description')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Detail root cause and operational impact..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority</label>
|
||||||
|
<select
|
||||||
|
{...issueForm.register('priority')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Assigned Person *</label>
|
||||||
|
<select
|
||||||
|
{...issueForm.register('assignedTo')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
<option value="">Select Assignee</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name || u.email || u.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsIssueModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createIssueMutation.isPending || updateIssueMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
|
||||||
|
>
|
||||||
|
{editingIssue ? 'Update Issue' : 'Log Issue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: In-place Resolve Issue */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isResolveIssueModalOpen}
|
||||||
|
onClose={() => setIsResolveIssueModalOpen(false)}
|
||||||
|
title={`Resolve Issue: ${resolvingIssue?.title || ''}`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={resolveIssueForm.handleSubmit((d) => resolveIssueMutation.mutate(d))} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Resolution Summary & Remediation Notes *</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
{...resolveIssueForm.register('resolution')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Explain how the issue was resolved or mitigated..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Status Transition</label>
|
||||||
|
<select
|
||||||
|
{...resolveIssueForm.register('status')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
<option value={ActivityStatus.Inactive}>Resolved & Closed</option>
|
||||||
|
<option value={ActivityStatus.Active}>Keep Open with Progress Notes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsResolveIssueModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={resolveIssueMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white transition shadow-md shadow-emerald-600/30"
|
||||||
|
>
|
||||||
|
Confirm Resolution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Create Change Request */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isChangeModalOpen}
|
||||||
|
onClose={() => setIsChangeModalOpen(false)}
|
||||||
|
title="Request Contractual & Budgetary Change"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={changeForm.handleSubmit((d) => createChangeMutation.mutate(d))} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Change Proposal Title *</label>
|
||||||
|
<input
|
||||||
|
{...changeForm.register('title')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="e.g. Scope expansion to include 2 additional clinics"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Business Justification *</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
{...changeForm.register('justification')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Provide statutory and contractual justification..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Revised Total Budget (ZAR) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...changeForm.register('revisedBudget')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Revision Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
{...changeForm.register('revisionDate')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsChangeModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createChangeMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-md shadow-amber-600/30"
|
||||||
|
>
|
||||||
|
Submit Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Resolve Change Request Decision */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isResolveChangeModalOpen}
|
||||||
|
onClose={() => setIsResolveChangeModalOpen(false)}
|
||||||
|
title={`Resolve Change: ${resolvingChange?.title || ''}`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={resolveChangeForm.handleSubmit((d) => resolveChangeMutation.mutate(d))} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Decision Justification / Sign-off Comments *</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
{...resolveChangeForm.register('justification')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsResolveChangeModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={resolveChangeMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
|
||||||
|
>
|
||||||
|
Apply Decision
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/form
|
|||||||
import { ApprovalStatus } from '../types';
|
import { ApprovalStatus } from '../types';
|
||||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
|
||||||
export const ExecutiveDashboard: React.FC = () => {
|
export const ExecutiveDashboard: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -328,6 +329,19 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [projects, filteredProjects, risks]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Page Title & Filter Bar */}
|
{/* Page Title & Filter Bar */}
|
||||||
@@ -767,23 +781,33 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
<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>
|
<SortableTh column="name" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Programme</th>
|
Project
|
||||||
<th className="py-3.5 px-4">Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-center">Risk</th>
|
<SortableTh column="programme" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4 text-right">Financial Variance</th>
|
Programme
|
||||||
<th className="py-3.5 px-5 text-right">Action</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
{attentionProjects.length === 0 ? (
|
{sortedAttentionProjects.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
|
<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.
|
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
attentionProjects.map((p) => (
|
sortedAttentionProjects.map((p) => (
|
||||||
<tr key={p.id} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={p.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5">
|
<td className="py-3.5 px-5">
|
||||||
<span className="font-semibold text-white block text-xs">{p.name}</span>
|
<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 { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||||
import { RulesSection } from '../components/governance/RulesSection';
|
import { RulesSection } from '../components/governance/RulesSection';
|
||||||
import {
|
import {
|
||||||
@@ -636,6 +637,36 @@ export const GovernancePage: React.FC = () => {
|
|||||||
return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q);
|
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
|
// Export Columns Definitions
|
||||||
const orgExportColumns: ExportColumn<Organisation>[] = [
|
const orgExportColumns: ExportColumn<Organisation>[] = [
|
||||||
{ header: 'Organisation ID', key: 'id' },
|
{ header: 'Organisation ID', key: 'id' },
|
||||||
@@ -891,12 +922,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
<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>
|
<SortableTh column="name" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Registration No</th>
|
Organisation Name
|
||||||
<th className="py-3.5 px-4">Entity Type</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Contact Email</th>
|
<SortableTh column="registrationNo" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Status</th>
|
Registration No
|
||||||
<th className="py-3.5 px-5 text-right">Actions</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
@@ -906,14 +947,14 @@ export const GovernancePage: React.FC = () => {
|
|||||||
Loading registered organisations...
|
Loading registered organisations...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredOrgs.length === 0 ? (
|
) : sortedOrgs.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
No organisations found matching query.
|
No organisations found matching query.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredOrgs.map((org) => {
|
sortedOrgs.map((org) => {
|
||||||
const statusBadge = getOrganisationStatusBadge(org);
|
const statusBadge = getOrganisationStatusBadge(org);
|
||||||
return (
|
return (
|
||||||
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
<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>
|
<SortableTh column="name" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Parent Portfolio</th>
|
Programme Name
|
||||||
<th className="py-3.5 px-4">Governance Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Creation Date</th>
|
<SortableTh column="portfolioId" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Owner Email</th>
|
Parent Portfolio
|
||||||
<th className="py-3.5 px-5 text-right">Actions</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60">
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
@@ -1162,14 +1213,14 @@ export const GovernancePage: React.FC = () => {
|
|||||||
Loading programmes...
|
Loading programmes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredProgrammes.length === 0 ? (
|
) : sortedProgrammes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
No programmes found matching query.
|
No programmes found matching query.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredProgrammes.map((pr) => {
|
sortedProgrammes.map((pr) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
||||||
const statusBadge = getApprovalStatusBadge(pr.status);
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Budget,
|
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);
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header & Execution Pipeline Summary */}
|
{/* Header & Execution Pipeline Summary */}
|
||||||
@@ -766,12 +810,24 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="referenceNo" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Recipient Organisation</th>
|
Approval Reference #
|
||||||
<th className="py-3.5 px-4">Funding Programme</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
|
<SortableTh column="organisationId" currentColumn={awardSortCol} direction={awardSortDir} onSort={handleAwardSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Status</th>
|
Recipient Organisation
|
||||||
<th className="py-3.5 px-5 text-right">Date Registered</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -781,14 +837,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Loading registered awards...
|
Loading registered awards...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredAwards.length === 0 ? (
|
) : sortedAwards.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||||||
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
|
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredAwards.map((awd) => {
|
sortedAwards.map((awd) => {
|
||||||
const statusBadge = getApprovalStatusBadge(awd.status);
|
const statusBadge = getApprovalStatusBadge(awd.status);
|
||||||
return (
|
return (
|
||||||
<tr key={awd.id} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="name" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Project ID</th>
|
Budget Line Category
|
||||||
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Date Established</th>
|
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Status</th>
|
Project ID
|
||||||
<th className="py-3.5 px-4">Created By</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right">Actions</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -883,14 +951,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Loading budget lines...
|
Loading budget lines...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredBudgets.length === 0 ? (
|
) : sortedBudgets.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
{searchTerm ? 'No budget lines match your search query.' : 'No budget lines established yet.'}
|
{searchTerm ? 'No budget lines match your search query.' : 'No budget lines established yet.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBudgets.map((b) => {
|
sortedBudgets.map((b) => {
|
||||||
const statusBadge = getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved);
|
const statusBadge = getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved);
|
||||||
return (
|
return (
|
||||||
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="id" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Project ID</th>
|
Disbursement ID
|
||||||
<th className="py-3.5 px-4">Milestone Association</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right">Tranche Amount</th>
|
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Payment Reference</th>
|
Project ID
|
||||||
<th className="py-3.5 px-4">Approval Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right">Approval Action</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -1021,14 +1101,14 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Loading disbursements...
|
Loading disbursements...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredDisbursements.length === 0 ? (
|
) : sortedDisbursements.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
{searchTerm ? 'No disbursements match your search query.' : 'No disbursement tranches recorded yet.'}
|
{searchTerm ? 'No disbursements match your search query.' : 'No disbursement tranches recorded yet.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredDisbursements.map((d) => {
|
sortedDisbursements.map((d) => {
|
||||||
const statusBadge = getApprovalStatusBadge(d.status);
|
const statusBadge = getApprovalStatusBadge(d.status);
|
||||||
const isApproved = d.status === ApprovalStatus.Approved;
|
const isApproved = d.status === ApprovalStatus.Approved;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
||||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
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);
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -643,14 +675,28 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Project ID</th>
|
Indicator Name
|
||||||
<th className="py-3.5 px-4">Unit of Measure</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-right">Baseline</th>
|
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4 text-right">Target</th>
|
Project ID
|
||||||
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Status</th>
|
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-5 text-right">Action</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -661,7 +707,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
Loading project indicators from API...
|
Loading project indicators from API...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredIndicators.length === 0 ? (
|
) : sortedIndicators.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
@@ -671,7 +717,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredIndicators.map((ind) => {
|
sortedIndicators.map((ind) => {
|
||||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||||
return (
|
return (
|
||||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="name" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Project ID</th>
|
Risk Title & Description
|
||||||
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
|
<SortableTh column="projectId" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4 text-center">Residual Score</th>
|
Project ID
|
||||||
<th className="py-3.5 px-4">Risk Owner</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5">Mitigation Control</th>
|
<SortableTh column="likelihood" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
|
||||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -851,7 +911,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
Loading risk register from API...
|
Loading risk register from API...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredRisks.length === 0 ? (
|
) : sortedRisks.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<ShieldAlert className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<ShieldAlert className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
@@ -861,7 +921,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredRisks.map((rk) => {
|
sortedRisks.map((rk) => {
|
||||||
const score = rk.likelihood * rk.impact;
|
const score = rk.likelihood * rk.impact;
|
||||||
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import {
|
import {
|
||||||
EmailStatuses,
|
EmailStatuses,
|
||||||
@@ -139,7 +140,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: () => syncUser(undefined, true),
|
mutationFn: () => syncUser(undefined, true),
|
||||||
onSuccess: () => {
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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
|
// 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 getActionBadge = (action?: string | null) => {
|
||||||
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||||
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
|
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
|
||||||
@@ -488,51 +517,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'notifications' && (
|
{activeTab === 'notifications' && (
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetchNotifications();
|
setInviteOrgId(effectiveNotificationOrgId);
|
||||||
success('Notifications Refreshed', 'Latest communication transmissions loaded from endpoint.');
|
setIsSendInviteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingNotifications}
|
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"
|
||||||
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' : ''}`} />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Refresh Notifications</span>
|
Add Notification
|
||||||
</button>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -659,10 +653,18 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="email" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">User Identifier</th>
|
User Email
|
||||||
<th className="py-3.5 px-4">Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Last Login</th>
|
<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>
|
<th className="py-3.5 px-5 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -673,14 +675,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Loading user directory...
|
Loading user directory...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : usersList.length === 0 ? (
|
) : sortedUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||||
No users found matching current filters.
|
No users found matching current filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
usersList.map((u) => {
|
sortedUsers.map((u) => {
|
||||||
const badge = getActivityStatusBadge(u.status);
|
const badge = getActivityStatusBadge(u.status);
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
|
<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">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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>
|
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||||
<th className="py-3.5 px-4">Channel</th>
|
Recipient (UUID)
|
||||||
<th className="py-3.5 px-4">Message</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Target Org / Grant</th>
|
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-5 text-right">Created Date</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -845,14 +857,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Loading invitations ledger...
|
Loading invitations ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : invitesList.length === 0 ? (
|
) : sortedInvites.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
invitesList.map((inv, idx) => (
|
sortedInvites.map((inv, idx) => (
|
||||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
<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]">
|
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||||
{inv.recipient}
|
{inv.recipient}
|
||||||
@@ -919,7 +931,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Total Notifications
|
Total Notifications
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xl font-bold font-mono text-white">{fetchedNotifications.length}</div>
|
<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>
|
||||||
|
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
||||||
@@ -966,8 +978,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter & Search Toolbar */}
|
{/* 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="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="relative flex-1 max-w-md">
|
<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" />
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -978,7 +991,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Target Org:</span>
|
<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" />
|
<Building2 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
@@ -1045,7 +1057,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 self-end sm:self-center shrink-0 flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1080,13 +1094,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Communications Telemetry"
|
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"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-slate-400 font-mono">
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
GET /v1/api/communications/norifications/{effectiveNotificationOrgId}
|
Org #{effectiveNotificationOrgId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1094,14 +1108,28 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<table className="w-full text-left border-collapse text-xs">
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400 font-medium">
|
<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>
|
<SortableTh column="id" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Channel / Platform</th>
|
Notification ID
|
||||||
<th className="py-3.5 px-4">Status</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Subject & Message Payload</th>
|
<SortableTh column="platform" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Recipient & Destination</th>
|
Channel / Platform
|
||||||
<th className="py-3.5 px-4">Target Org</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Timestamp</th>
|
<SortableTh column="status" currentColumn={notifSortCol} direction={notifSortDir} onSort={handleNotifSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -1109,10 +1137,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<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" />
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredNotifications.length === 0 ? (
|
) : sortedNotifications.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
@@ -1120,7 +1148,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredNotifications.map((item) => {
|
sortedNotifications.map((item) => {
|
||||||
const statusBadge = getNotificationStatusBadge(item.status);
|
const statusBadge = getNotificationStatusBadge(item.status);
|
||||||
return (
|
return (
|
||||||
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
@@ -1332,13 +1360,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Audit Trail"
|
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"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-slate-400 font-mono">
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
POST /v1/api/governance/auditlog
|
{filteredAuditLogs.length} entries
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1347,13 +1375,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<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-3 w-10 text-center"></th>
|
||||||
<th className="py-3.5 px-4">Timestamp</th>
|
<SortableTh column="timestamp" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Event ID</th>
|
Timestamp
|
||||||
<th className="py-3.5 px-4">Action</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-4">Entity</th>
|
<SortableTh column="id" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||||
<th className="py-3.5 px-4">Performed By</th>
|
Event ID
|
||||||
<th className="py-3.5 px-4">Changes</th>
|
</SortableTh>
|
||||||
<th className="py-3.5 px-5 text-right">Inspect</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
@@ -1361,20 +1401,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<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" />
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredAuditLogs.length === 0 ? (
|
) : sortedAuditLogs.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
||||||
? 'No audit log entries match your filter criteria.'
|
? 'No audit log entries match your filter criteria.'
|
||||||
: 'No audit records returned from endpoint.'}
|
: 'No audit records found.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredAuditLogs.map((log) => {
|
sortedAuditLogs.map((log) => {
|
||||||
const badge = getActionBadge(log.action);
|
const badge = getActionBadge(log.action);
|
||||||
const isExpanded = !!expandedAuditLogIds[log.id];
|
const isExpanded = !!expandedAuditLogIds[log.id];
|
||||||
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
||||||
@@ -1574,7 +1614,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setCopiedAuditPayload(false);
|
setCopiedAuditPayload(false);
|
||||||
}}
|
}}
|
||||||
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
|
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
|
||||||
subtitle="Authoritative event payload from /v1/api/governance/auditlog"
|
subtitle="Authoritative event payload and activity ledger"
|
||||||
>
|
>
|
||||||
{selectedAuditLog && (
|
{selectedAuditLog && (
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
@@ -2111,7 +2151,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{/* Read-only Engine Notice */}
|
{/* 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">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Plus,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
MessageSquare,
|
||||||
|
Edit2,
|
||||||
|
FolderKanban,
|
||||||
|
FileText,
|
||||||
|
ShieldCheck,
|
||||||
|
Building,
|
||||||
|
Send,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getProjects,
|
||||||
|
getProjectSiteVisits,
|
||||||
|
createSiteVisit,
|
||||||
|
updateSiteVisit,
|
||||||
|
} from '../services/api';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { useToast } from '../components/ui/Toast';
|
||||||
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { SiteVisit, ApprovalStatus } from '../types';
|
||||||
|
import {
|
||||||
|
getApprovalStatusBadge,
|
||||||
|
formatDate,
|
||||||
|
APPROVAL_STATUS_OPTIONS,
|
||||||
|
} from '../lib/formatters';
|
||||||
|
|
||||||
|
const siteVisitSchema = z.object({
|
||||||
|
inspectorName: z.string().min(2, 'Inspector name required'),
|
||||||
|
visitedAt: z.string().min(4, 'Visit date is required'),
|
||||||
|
findings: z.string().min(5, 'Field findings and observations required'),
|
||||||
|
status: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
|
||||||
|
|
||||||
|
export const SiteVisitsPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [isVisitModalOpen, setIsVisitModalOpen] = useState(false);
|
||||||
|
const [editingVisit, setEditingVisit] = useState<SiteVisit | null>(null);
|
||||||
|
|
||||||
|
const [selectedVisitForDetail, setSelectedVisitForDetail] = useState<SiteVisit | null>(null);
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: siteVisits = [],
|
||||||
|
isLoading: loadingVisits,
|
||||||
|
refetch: refetchVisits,
|
||||||
|
isFetching: fetchingVisits,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['siteVisits', effectiveProjectId],
|
||||||
|
queryFn: () => (effectiveProjectId ? getProjectSiteVisits(effectiveProjectId) : Promise.resolve([])),
|
||||||
|
enabled: !!effectiveProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const visitForm = useForm<SiteVisitFormValues>({
|
||||||
|
resolver: zodResolver(siteVisitSchema),
|
||||||
|
defaultValues: {
|
||||||
|
inspectorName: '',
|
||||||
|
visitedAt: new Date().toISOString().split('T')[0],
|
||||||
|
findings: '',
|
||||||
|
status: ApprovalStatus.Pending,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createVisitMutation = useMutation({
|
||||||
|
mutationFn: (vals: SiteVisitFormValues) => {
|
||||||
|
if (!effectiveProjectId) throw new Error('No project selected');
|
||||||
|
return createSiteVisit({
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
inspectorName: vals.inspectorName,
|
||||||
|
findings: vals.findings,
|
||||||
|
visitedAt: vals.visitedAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['siteVisits'] });
|
||||||
|
setIsVisitModalOpen(false);
|
||||||
|
visitForm.reset();
|
||||||
|
success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Logging Failed', err.message || 'Unable to save site visit.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateVisitMutation = useMutation({
|
||||||
|
mutationFn: (vals: SiteVisitFormValues) => {
|
||||||
|
if (!editingVisit) throw new Error('No visit selected');
|
||||||
|
return updateSiteVisit({
|
||||||
|
siteVisitId: editingVisit.id,
|
||||||
|
inspectorName: vals.inspectorName,
|
||||||
|
findings: vals.findings,
|
||||||
|
status: vals.status ?? ApprovalStatus.Pending,
|
||||||
|
visitedAt: vals.visitedAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['siteVisits'] });
|
||||||
|
setIsVisitModalOpen(false);
|
||||||
|
setEditingVisit(null);
|
||||||
|
visitForm.reset();
|
||||||
|
success('Site Visit Updated', 'Inspection report and verification status updated.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update site visit.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered & Sorted Visits
|
||||||
|
const filteredVisits = useMemo(() => {
|
||||||
|
return siteVisits.filter((v) => {
|
||||||
|
return (
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
(v.inspectorName && v.inspectorName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(v.findings && v.findings.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [siteVisits, searchTerm]);
|
||||||
|
|
||||||
|
const { sortedData: sortedVisits, sortColumn: visitSortCol, sortDirection: visitSortDir, handleSort: handleVisitSort } =
|
||||||
|
useTableSort(filteredVisits);
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
const visitExportCols: ExportColumn<SiteVisit>[] = [
|
||||||
|
{ header: 'Inspector Name', key: 'inspectorName' },
|
||||||
|
{ header: 'Visit Date', accessor: (v) => formatDate(v.visitedAt) },
|
||||||
|
{ header: 'Findings', key: 'findings' },
|
||||||
|
{ header: 'Status', accessor: (v) => (v.status === ApprovalStatus.Approved ? 'Approved' : 'Pending') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleOpenEdit = (v: SiteVisit) => {
|
||||||
|
setEditingVisit(v);
|
||||||
|
visitForm.reset({
|
||||||
|
inspectorName: v.inspectorName || '',
|
||||||
|
visitedAt: v.visitedAt ? v.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
findings: v.findings || '',
|
||||||
|
status: v.status ?? ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
setIsVisitModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-16">
|
||||||
|
{/* Header Banner */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="space-y-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 shadow-lg shadow-emerald-500/10">
|
||||||
|
<MapPin className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
|
Site Visits & Field Verification
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
|
Log physical inspection reports, record field findings, and capture project feedback.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => refetchVisits()}
|
||||||
|
disabled={fetchingVisits}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-semibold bg-slate-900/90 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800/80 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${fetchingVisits ? 'animate-spin text-emerald-400' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton data={sortedVisits} columns={visitExportCols} filename="site_visits" title="Site Visits Log" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingVisit(null);
|
||||||
|
visitForm.reset({
|
||||||
|
inspectorName: '',
|
||||||
|
visitedAt: new Date().toISOString().split('T')[0],
|
||||||
|
findings: '',
|
||||||
|
status: ApprovalStatus.Pending,
|
||||||
|
});
|
||||||
|
setIsVisitModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Log Site Visit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Total Site Visits</span>
|
||||||
|
<MapPin className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{siteVisits.length}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Field inspections logged</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Approved Reports</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-teal-400">
|
||||||
|
{siteVisits.filter((v) => v.status === ApprovalStatus.Approved).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Verified by project governance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Under Review</span>
|
||||||
|
<FileText className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-amber-400">
|
||||||
|
{siteVisits.filter((v) => v.status === ApprovalStatus.Pending || v.status === ApprovalStatus.Review).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Awaiting sign-off</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Audited Projects</span>
|
||||||
|
<Building className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-blue-400">{projects.length}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">Under physical monitoring</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<FolderKanban className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Project:</span>
|
||||||
|
<select
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search inspector / findings..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 w-48 sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Inspector Name
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Inspection Date
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Field Observations & Findings
|
||||||
|
</SortableTh>
|
||||||
|
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
|
||||||
|
Verification Status
|
||||||
|
</SortableTh>
|
||||||
|
<th className="py-3 px-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-xs">
|
||||||
|
{loadingVisits ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Loading site visit logs...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedVisits.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
||||||
|
No site visits recorded for this project yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedVisits.map((v) => (
|
||||||
|
<tr key={v.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-3.5 px-4 font-semibold text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span>{v.inspectorName || 'Field Officer'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 font-medium">
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-300">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{formatDate(v.visitedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
|
||||||
|
{v.findings || 'No findings recorded.'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedVisitForDetail(v);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/40 rounded-lg border border-emerald-800/40 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
View & Feedback
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(v)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
||||||
|
title="Edit site visit"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Create/Edit Site Visit */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isVisitModalOpen}
|
||||||
|
onClose={() => setIsVisitModalOpen(false)}
|
||||||
|
title={editingVisit ? 'Edit Site Visit Report' : 'Log Project Site Visit'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={visitForm.handleSubmit((d) => (editingVisit ? updateVisitMutation.mutate(d) : createVisitMutation.mutate(d)))}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Inspector / Officer Name *</label>
|
||||||
|
<input
|
||||||
|
{...visitForm.register('inspectorName')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="e.g. Dr. K. Mokoena (Lead M&E Auditor)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Inspection Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
{...visitForm.register('visitedAt')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Field Findings & Observations *</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
{...visitForm.register('findings')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Detail on-site verification, physical progress, equipment installed, and beneficiary feedback..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingVisit && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
||||||
|
<select
|
||||||
|
{...visitForm.register('status')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsVisitModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createVisitMutation.isPending || updateVisitMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white transition shadow-md shadow-emerald-600/30"
|
||||||
|
>
|
||||||
|
{editingVisit ? 'Update Report' : 'Save Site Visit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: Detail & Feedback */}
|
||||||
|
{selectedVisitForDetail && (
|
||||||
|
<Modal
|
||||||
|
isOpen={isDetailModalOpen}
|
||||||
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
|
title={`Site Visit: ${selectedVisitForDetail.inspectorName || 'Field Inspection'}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Inspection Date</span>
|
||||||
|
<span className="text-xs font-bold text-white">{formatDate(selectedVisitForDetail.visitedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Status</span>
|
||||||
|
<div className="mt-0.5">{getApprovalStatusBadge(selectedVisitForDetail.status)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Inspector</span>
|
||||||
|
<span className="text-xs font-bold text-emerald-400">{selectedVisitForDetail.inspectorName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Field Findings & Observations</span>
|
||||||
|
</h4>
|
||||||
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 text-xs text-slate-200 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{selectedVisitForDetail.findings || 'No detailed findings recorded.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback & Governance Notes Section */}
|
||||||
|
<div className="space-y-3 pt-4 border-t border-slate-800">
|
||||||
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4 text-blue-400" />
|
||||||
|
<span>Governance & Remediation Feedback</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-950/20 border border-blue-800/40 rounded-xl text-xs text-blue-300">
|
||||||
|
Site visit report is documented in the central monitoring audit ledger. You can update findings or sign off this
|
||||||
|
field evaluation using the edit report action.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
|
export type SortDirection = 'asc' | 'desc' | null;
|
||||||
|
|
||||||
|
export interface SortConfig<T> {
|
||||||
|
column: string | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
customExtractors?: Record<string, (item: T) => any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTableSort<T>(
|
||||||
|
data: T[],
|
||||||
|
initialColumn: string | null = null,
|
||||||
|
initialDirection: SortDirection = null,
|
||||||
|
customExtractors?: Record<string, (item: T) => any>
|
||||||
|
) {
|
||||||
|
const [sortColumn, setSortColumn] = useState<string | null>(initialColumn);
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>(initialDirection);
|
||||||
|
|
||||||
|
const handleSort = useCallback((column: string) => {
|
||||||
|
if (sortColumn !== column) {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection('asc');
|
||||||
|
} else {
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
setSortDirection('desc');
|
||||||
|
} else if (sortDirection === 'desc') {
|
||||||
|
setSortDirection(null);
|
||||||
|
setSortColumn(null);
|
||||||
|
} else {
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sortColumn || !sortDirection || !Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...data];
|
||||||
|
return items.sort((a: any, b: any) => {
|
||||||
|
let valA = customExtractors && customExtractors[sortColumn]
|
||||||
|
? customExtractors[sortColumn](a)
|
||||||
|
: a?.[sortColumn];
|
||||||
|
|
||||||
|
let valB = customExtractors && customExtractors[sortColumn]
|
||||||
|
? customExtractors[sortColumn](b)
|
||||||
|
: b?.[sortColumn];
|
||||||
|
|
||||||
|
if (valA === undefined || valA === null) valA = '';
|
||||||
|
if (valB === undefined || valB === null) valB = '';
|
||||||
|
|
||||||
|
// Number comparison
|
||||||
|
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||||
|
return sortDirection === 'asc' ? valA - valB : valB - valA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric string comparison
|
||||||
|
const numA = Number(valA);
|
||||||
|
const numB = Number(valB);
|
||||||
|
if (!isNaN(numA) && !isNaN(numB) && typeof valA !== 'boolean' && typeof valB !== 'boolean' && String(valA).trim() !== '' && String(valB).trim() !== '') {
|
||||||
|
return sortDirection === 'asc' ? numA - numB : numB - numA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date comparison if valid ISO / date string
|
||||||
|
const dateA = Date.parse(String(valA));
|
||||||
|
const dateB = Date.parse(String(valB));
|
||||||
|
if (!isNaN(dateA) && !isNaN(dateB) && (String(valA).includes('-') || String(valA).includes('/'))) {
|
||||||
|
return sortDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String comparison
|
||||||
|
const strA = String(valA).toLowerCase();
|
||||||
|
const strB = String(valB).toLowerCase();
|
||||||
|
if (strA < strB) return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
if (strA > strB) return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [data, sortColumn, sortDirection, customExtractors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortedData,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableThProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
||||||
|
column: string;
|
||||||
|
currentColumn: string | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
onSort: (column: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortableTh: React.FC<SortableThProps> = ({
|
||||||
|
column,
|
||||||
|
currentColumn,
|
||||||
|
direction,
|
||||||
|
onSort,
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isCurrent = currentColumn === column && direction !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
onClick={() => onSort(column)}
|
||||||
|
className={`cursor-pointer select-none group transition-colors hover:text-white ${className}`}
|
||||||
|
title={`Click to sort (Ascending, Descending, Clear)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1 ${
|
||||||
|
align === 'right' ? 'justify-end w-full' : align === 'center' ? 'justify-center w-full' : 'justify-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
{isCurrent && direction === 'asc' && (
|
||||||
|
<ArrowUp className="w-3 h-3 text-blue-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
{isCurrent && direction === 'desc' && (
|
||||||
|
<ArrowDown className="w-3 h-3 text-blue-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
{!isCurrent && (
|
||||||
|
<ArrowUpDown className="w-3 h-3 text-slate-600 group-hover:text-slate-400 opacity-40 group-hover:opacity-100 shrink-0 transition" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user