Files
pfm/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx
T
hermes 0261a6093e Refactored project view into modal
Elevated toast message priority
2026-10-22 09:41:41 +02:00

1133 lines
51 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
FolderKanban,
Plus,
Search,
RotateCcw,
ChevronDown,
ChevronRight,
Flag,
Briefcase,
Target,
ShieldAlert,
Edit2,
Calendar,
Clock,
TrendingUp,
FileCheck,
AlertTriangle,
AlertCircle,
CheckCircle2,
SlidersHorizontal,
Eye,
} from 'lucide-react';
import {
getProjects,
getAllProgrammes,
getProgrammeProjects,
getAllMilestones,
getAllRisks,
getAllIndicators,
getBudgets,
updateProjectStatus,
} from '../services/api';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useToast } from '../components/ui/Toast';
import { useRbac } from '../auth/rbac';
import { useTableSort, SortableTh } from '../utils/tableSort';
import {
Project,
Programme,
Milestone,
ApprovalStatus,
IndustrySector,
ProjectType,
} from '../types';
import {
getIndustrySectorLabel,
getProjectTypeLabel,
getApprovalStatusBadge,
formatDate,
formatZAR,
INDUSTRY_SECTOR_OPTIONS,
PROJECT_TYPE_OPTIONS,
APPROVAL_STATUS_OPTIONS,
getUserEmail,
} from '../lib/formatters';
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
import { ProjectIndicatorsModal } from '../components/projects/ProjectIndicatorsModal';
import { ProjectRisksModal } from '../components/projects/ProjectRisksModal';
export interface ProjectScheduleRisk {
isAtRisk: boolean;
isOverdue: boolean;
daysRemaining: number | null;
reason: string;
badgeClass: string;
iconType: 'critical' | 'warning' | 'on_track' | 'pending';
}
export const evaluateProjectScheduleRisk = (
project: Project,
milestoneStats?: { total: number; verified: number }
): ProjectScheduleRisk => {
const hasStarted = Boolean(project.startedAt);
const targetDateStr = project.endedAt;
if (!targetDateStr) {
return {
isAtRisk: false,
isOverdue: false,
daysRemaining: null,
reason: 'No contractual target completion date configured yet.',
badgeClass: 'bg-slate-800 text-slate-400 border-slate-700/80',
iconType: 'pending',
};
}
const now = Date.now();
const targetTime = new Date(targetDateStr).getTime();
const diffDays = Math.ceil((targetTime - now) / (1000 * 60 * 60 * 24));
const totalMilestones = milestoneStats?.total || 0;
const verifiedMilestones = milestoneStats?.verified || 0;
const isMilestonesIncomplete = totalMilestones === 0 || verifiedMilestones < totalMilestones;
if (hasStarted && isMilestonesIncomplete) {
if (diffDays < 0) {
return {
isAtRisk: true,
isOverdue: true,
daysRemaining: diffDays,
reason: `Target completion deadline passed ${Math.abs(diffDays)} day${
Math.abs(diffDays) === 1 ? '' : 's'
} ago with ${totalMilestones - verifiedMilestones} unverified milestone deliverable(s) remaining.`,
badgeClass: 'bg-rose-500/15 text-rose-400 border-rose-500/40 font-bold animate-pulse',
iconType: 'critical',
};
}
if (diffDays <= 30) {
return {
isAtRisk: true,
isOverdue: false,
daysRemaining: diffDays,
reason: `Project is ${diffDays} day${
diffDays === 1 ? '' : 's'
} away from target due date with only ${verifiedMilestones} of ${totalMilestones} milestones verified.`,
badgeClass: 'bg-amber-500/15 text-amber-300 border-amber-500/40 font-semibold',
iconType: 'warning',
};
}
}
if (totalMilestones > 0 && verifiedMilestones === totalMilestones) {
return {
isAtRisk: false,
isOverdue: false,
daysRemaining: diffDays,
reason: `All ${totalMilestones} contractual milestone deliverables verified and approved.`,
badgeClass: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
iconType: 'on_track',
};
}
return {
isAtRisk: false,
isOverdue: false,
daysRemaining: diffDays,
reason: `${diffDays} days remaining until target completion deadline.`,
badgeClass: 'bg-slate-800/80 text-slate-300 border-slate-700/80',
iconType: 'on_track',
};
};
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter & Search States
const [searchTerm, setSearchTerm] = useState('');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
const [selectedSectorFilter, setSelectedSectorFilter] = useState<string | number | 'all'>('all');
const [selectedStatusFilter, setSelectedStatusFilter] = useState<string | number | 'all'>('all');
// Active Project View Modal State
const [viewingProject, setViewingProject] = useState<Project | null>(null);
const [isProjectViewModalOpen, setIsProjectViewModalOpen] = useState(false);
// Modals Management State
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [activeModalProject, setActiveModalProject] = useState<Project | null>(null);
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
const [isRisksModalOpen, setIsRisksModalOpen] = useState(false);
// Queries
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const {
data: projects = [],
isLoading: loadingProjects,
refetch: refetchProjects,
isFetching: isFetchingProjects,
} = useQuery({
queryKey: ['projects', selectedProgrammeFilter],
queryFn: () =>
selectedProgrammeFilter !== 'all'
? getProgrammeProjects(selectedProgrammeFilter)
: getProjects(),
});
const { data: allMilestones = [] } = useQuery({
queryKey: ['milestones-all'],
queryFn: () => getAllMilestones(),
});
const { data: allRisks = [] } = useQuery({
queryKey: ['risks-all'],
queryFn: () => getAllRisks(),
});
const { data: allIndicators = [] } = useQuery({
queryKey: ['indicators-all'],
queryFn: () => getAllIndicators(),
});
const { data: allBudgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
});
// Programme lookup map
const programmeMap = useMemo(() => {
const map = new Map<string | number, Programme>();
programmes.forEach((p) => map.set(p.id, p));
return map;
}, [programmes]);
// Project Milestones Count Map
const milestonesByProject = useMemo(() => {
const map = new Map<string | number, { total: number; verified: number }>();
allMilestones.forEach((m) => {
const pId = m.projectId;
if (pId !== undefined && pId !== null) {
const current = map.get(pId) || { total: 0, verified: 0 };
current.total += 1;
if (m.status === ApprovalStatus.Approved) {
current.verified += 1;
}
map.set(pId, current);
}
});
return map;
}, [allMilestones]);
// Project Approved Budget Map
const approvedBudgetsByProject = useMemo(() => {
const map = new Map<string | number, number>();
allBudgets.forEach((b) => {
const pId = b.projectId;
if (pId !== undefined && pId !== null) {
if (
b.status === undefined ||
b.status === null ||
Number(b.status) === ApprovalStatus.Approved ||
Number(b.status) === 3
) {
const current = map.get(pId) || 0;
map.set(pId, current + (Number(b.amount) || 0));
}
}
});
return map;
}, [allBudgets]);
// Status Mutation
const statusMutation = useMutation({
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
updateProjectStatus({ projectId, status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success('Status Updated', 'Project governance status changed successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', err);
},
});
// Filtered Projects
const filteredProjects = useMemo(() => {
return projects.filter((p) => {
// Search term
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase();
const matchesName = (p.name || '').toLowerCase().includes(query);
const matchesDesc = (p.description || '').toLowerCase().includes(query);
const matchesId = String(p.id).toLowerCase().includes(query);
const matchesSector = getIndustrySectorLabel(p.sector).toLowerCase().includes(query);
if (!matchesName && !matchesDesc && !matchesId && !matchesSector) {
return false;
}
}
// Sector filter
if (selectedSectorFilter !== 'all' && Number(p.sector) !== Number(selectedSectorFilter)) {
return false;
}
// Status filter
if (selectedStatusFilter !== 'all' && Number(p.status) !== Number(selectedStatusFilter)) {
return false;
}
return true;
});
}, [projects, searchTerm, selectedSectorFilter, selectedStatusFilter]);
// Export Columns Configuration
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
{ header: 'Project ID', key: 'id', format: (val) => `#${val}` },
{ header: 'Project Name', key: 'name', format: (val) => val || 'Untitled' },
{
header: 'Parent Programme',
key: 'programmeId',
format: (val) => programmeMap.get(val)?.name || `Prog #${val}`,
},
{ header: 'Industry Sector', key: 'sector', format: (val) => getIndustrySectorLabel(val) },
{ header: 'Project Type', key: 'projectType', format: (val) => getProjectTypeLabel(val) },
{ header: 'Start Date', key: 'startedAt', format: (val) => formatDate(val) },
{ header: 'Target End Date', key: 'endedAt', format: (val) => formatDate(val) },
{
header: 'Schedule Risk Alert',
key: 'id',
format: (_, p) => {
const stats = milestonesByProject.get(p.id);
const risk = evaluateProjectScheduleRisk(p, stats);
return risk.isAtRisk ? (risk.isOverdue ? `OVERDUE: ${risk.reason}` : `AT RISK: ${risk.reason}`) : 'ON TRACK';
},
},
{
header: 'Milestone Progress',
key: 'id',
format: (_, p) => {
const stats = milestonesByProject.get(p.id);
if (!stats || stats.total === 0) return '0 / 0 (0%)';
const pct = Math.round((stats.verified / stats.total) * 100);
return `${stats.verified} / ${stats.total} (${pct}%)`;
},
},
{
header: 'Approved Budget (ZAR)',
key: 'id',
format: (_, p) => {
const amt = approvedBudgetsByProject.get(p.id) || 0;
return amt > 0 ? formatZAR(amt) : 'R 0';
},
},
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
// Table Sorting
const customExtractors = {
name: (p: Project) => (p.name || '').toLowerCase(),
programme: (p: Project) => (programmeMap.get(p.programmeId)?.name || '').toLowerCase(),
status: (p: Project) => Number(p.status) || 0,
progress: (p: Project) => {
const stats = milestonesByProject.get(p.id);
return stats && stats.total > 0 ? (stats.verified / stats.total) * 100 : 0;
},
duration: (p: Project) => {
if (!p.startedAt || !p.endedAt) return 0;
return new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime();
},
risk: (p: Project) => {
const stats = milestonesByProject.get(p.id);
const risk = evaluateProjectScheduleRisk(p, stats);
if (risk.isOverdue) return 1;
if (risk.isAtRisk) return 2;
return 3;
},
};
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
filteredProjects,
'risk',
'asc',
customExtractors
);
// Handler to open comprehensive Project View Modal
const handleOpenProjectView = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setViewingProject(p);
setIsProjectViewModalOpen(true);
};
// Handlers to open resource modals
const handleOpenMilestones = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setActiveModalProject(p);
setIsMilestonesModalOpen(true);
};
const handleOpenActivities = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setActiveModalProject(p);
setIsActivitiesModalOpen(true);
};
const handleOpenIndicators = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setActiveModalProject(p);
setIsIndicatorsModalOpen(true);
};
const handleOpenRisks = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setActiveModalProject(p);
setIsRisksModalOpen(true);
};
const handleOpenEditProject = (p: Project, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setEditingProject(p);
setIsProjectModalOpen(true);
};
const handleOpenCreateProject = () => {
setEditingProject(null);
setIsProjectModalOpen(true);
};
const resetFilters = () => {
setSearchTerm('');
setSelectedProgrammeFilter('all');
setSelectedSectorFilter('all');
setSelectedStatusFilter('all');
};
// Key KPI Aggregations
const totalApprovedProjects = projects.filter((p) => p.status === ApprovalStatus.Approved).length;
const verifiedMilestonesTotal = allMilestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneCompletionRate =
allMilestones.length > 0 ? Math.round((verifiedMilestonesTotal / allMilestones.length) * 100) : 0;
const atRiskProjectsCount = useMemo(() => {
return projects.filter((p) => {
const stats = milestonesByProject.get(p.id);
return evaluateProjectScheduleRisk(p, stats).isAtRisk;
}).length;
}, [projects, milestonesByProject]);
return (
<div className="space-y-6 w-full max-w-7xl 2xl:max-w-[1600px] mx-auto pb-12">
{/* 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">
<FolderKanban className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Project & Milestone Management
</h1>
<HelpTooltip
title="Work Breakdown Structure (WBS)"
content="Interactive table of funded projects. Expand any row to inspect timeline Gantt charts, deliverables, activities, M&E indicators, and risk registers."
size="sm"
placement="bottom"
/>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Interactive WBS Console
</span>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Manage operational activities, verified contractual milestone deliverables, risk matrices, and M&E performance indicator targets.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
{canAdd('projects_milestones') && (
<button
onClick={handleOpenCreateProject}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Create Project</span>
</button>
)}
</div>
</div>
{/* KPI Overview Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Projects</span>
<FolderKanban className="w-4 h-4 text-blue-400" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{projects.length}</span>
<span className="text-xs text-slate-400">({totalApprovedProjects} Approved)</span>
</div>
<p className="text-[11px] text-slate-500 mt-1">Under {programmes.length} parent programmes</p>
</div>
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Milestone Progress</span>
<Flag className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-emerald-400">{milestoneCompletionRate}%</span>
<span className="text-xs text-slate-400">({verifiedMilestonesTotal}/{allMilestones.length})</span>
</div>
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-2 overflow-hidden border border-slate-800">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${milestoneCompletionRate}%` }}
/>
</div>
</div>
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Schedule Risk Alerts</span>
<AlertTriangle className={`w-4 h-4 ${atRiskProjectsCount > 0 ? 'text-amber-400 animate-pulse' : 'text-slate-500'}`} />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className={`text-2xl font-bold ${atRiskProjectsCount > 0 ? 'text-amber-400' : 'text-slate-300'}`}>
{atRiskProjectsCount}
</span>
<span className="text-xs text-slate-400">Imminent / Overdue</span>
</div>
<p className="text-[11px] text-slate-500 mt-1">Due with unverified deliverables</p>
</div>
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">M&E Indicators</span>
<Target className="w-4 h-4 text-indigo-400" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-indigo-400">{allIndicators.length}</span>
<span className="text-xs text-slate-400">Tracked KPIs</span>
</div>
<p className="text-[11px] text-slate-500 mt-1">Results & impact metrics</p>
</div>
</div>
{/* Filter and Search Controls Bar */}
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm space-y-3">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search projects by name, ID, sector, or keywords..."
className="w-full pl-9 pr-3 py-2 bg-slate-900/90 border border-slate-700/80 rounded-xl text-xs text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
</div>
{/* Quick Filters */}
<div className="flex flex-wrap items-center gap-2.5">
{/* Programme Filter */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Programme:</span>
<select
value={selectedProgrammeFilter}
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[180px] cursor-pointer"
>
<option value="all">All Programmes</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
{/* Sector Filter */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Sector:</span>
<select
value={selectedSectorFilter}
onChange={(e) => setSelectedSectorFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[160px] cursor-pointer"
>
<option value="all">All Sectors</option>
{INDUSTRY_SECTOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Status Filter */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Status:</span>
<select
value={selectedStatusFilter}
onChange={(e) => setSelectedStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[140px] cursor-pointer"
>
<option value="all">All Statuses</option>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Clear Filter Button */}
{(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
<button
type="button"
onClick={resetFilters}
title="Reset all search and filter settings"
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/80 rounded-xl transition cursor-pointer"
>
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-400" />
<span>Reset</span>
</button>
)}
{/* Export Button */}
<ExportButton
claim="projects_milestones"
title="Projects & Milestones Register"
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
filename="projects-milestones-register"
columns={projectExportColumns}
data={sortedProjects}
size="sm"
/>
{/* Single Refresh Button */}
<button
type="button"
onClick={() => {
refetchProjects();
queryClient.invalidateQueries({ queryKey: ['milestones-all'] });
queryClient.invalidateQueries({ queryKey: ['activities'] });
queryClient.invalidateQueries({ queryKey: ['risks-all'] });
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
}}
title="Refresh project and milestone datasets"
className={`p-2 rounded-xl text-blue-400 hover:text-blue-300 bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 transition cursor-pointer ${
isFetchingProjects ? 'animate-spin' : ''
}`}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
{/* Active Filter Tags */}
{(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
<div className="flex items-center gap-2 pt-2 border-t border-slate-800/60 text-xs text-slate-400 flex-wrap">
<span className="text-[11px] font-semibold text-slate-500">Active Filters:</span>
{searchTerm && (
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
Search: &quot;{searchTerm}&quot;
</span>
)}
{selectedProgrammeFilter !== 'all' && (
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
Prog: {programmeMap.get(selectedProgrammeFilter)?.name || `#${selectedProgrammeFilter}`}
</span>
)}
{selectedSectorFilter !== 'all' && (
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
Sector: {getIndustrySectorLabel(Number(selectedSectorFilter))}
</span>
)}
{selectedStatusFilter !== 'all' && (
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
Status: {getApprovalStatusBadge(Number(selectedStatusFilter)).label}
</span>
)}
<button
onClick={resetFilters}
className="text-[11px] text-blue-400 hover:underline cursor-pointer ml-1"
>
Clear all
</button>
</div>
)}
</div>
{/* Main Projects Table Container */}
<div className="bg-[#0f1523]/80 rounded-3xl border border-slate-800/80 shadow-2xl backdrop-blur-md overflow-hidden">
{/* Table Header Bar */}
<div className="p-4 sm:px-6 border-b border-slate-800/80 flex items-center justify-between flex-wrap gap-3 bg-[#0d121f]/90">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
Funded Projects Directory ({sortedProjects.length})
</h3>
<span className="text-xs text-slate-400 hidden sm:inline">
&bull; Click "View Project" or project row to open the Project View modal & Gantt timeline
</span>
</div>
</div>
{/* Projects Table */}
<div className="overflow-x-auto scrollbar-thin">
<table className="w-full text-left text-xs text-slate-300 border-collapse min-w-full">
<thead>
<tr className="border-b border-slate-800/80 bg-[#090d16] text-[11px] font-semibold text-slate-400 select-none">
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 min-w-[160px] max-w-[240px]">
Project Name & ID
</SortableTh>
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-36">
Parent Programme
</SortableTh>
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-36 whitespace-nowrap">
Schedule (Timeline)
</SortableTh>
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-28 whitespace-nowrap">
WBS Progress
</SortableTh>
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-32 whitespace-nowrap">
<div className="flex items-center gap-1">
<span>Schedule Risk</span>
<HelpTooltip
title="Schedule Risk Assessment"
content="Identifies projects that have started and are approaching their target due date (or are overdue) while statutory deliverables/milestones remain unverified."
size="sm"
placement="bottom"
/>
</div>
</SortableTh>
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-32 whitespace-nowrap">
Status
</SortableTh>
<th className="py-3 px-3 text-right font-semibold whitespace-nowrap w-44">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-medium">
{loadingProjects ? (
<tr>
<td colSpan={7} className="p-12 text-center text-slate-400 text-xs">
<div className="flex flex-col items-center justify-center space-y-2">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Loading project directory...</span>
</div>
</td>
</tr>
) : sortedProjects.length === 0 ? (
<tr>
<td colSpan={7} className="p-12 text-center text-slate-400">
<div className="flex flex-col items-center justify-center space-y-3">
<FolderKanban className="w-10 h-10 text-slate-600" />
<p className="text-sm font-semibold text-slate-300">No matching projects found</p>
<p className="text-xs text-slate-500">
{searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all'
? 'Try adjusting your search query or filters.'
: 'Register a project to begin building your work breakdown structure.'}
</p>
<button
type="button"
onClick={handleOpenCreateProject}
className="mt-2 inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
>
<Plus className="w-4 h-4" /> Create First Project
</button>
</div>
</td>
</tr>
) : (
sortedProjects.map((p) => {
const prog = programmeMap.get(p.programmeId);
const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 };
const progressPct =
milestoneStats.total > 0
? Math.round((milestoneStats.verified / milestoneStats.total) * 100)
: 0;
const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats);
return (
<tr
key={p.id}
onClick={() => handleOpenProjectView(p)}
className="transition-colors cursor-pointer hover:bg-slate-900/60 text-slate-300 group"
>
{/* Project Name & Meta */}
<td className="py-3.5 px-3 max-w-[240px]">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span
className="font-bold text-sm text-white group-hover:text-blue-300 transition line-clamp-1"
title={p.name || `Project #${p.id}`}
>
{p.name || `Project #${p.id}`}
</span>
</div>
<div className="flex items-center gap-1.5 flex-wrap text-[11px]">
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/10 px-1.5 py-0.2 rounded border border-blue-500/25 shrink-0">
#{p.id}
</span>
<span
className="bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded text-slate-300 text-[10px] truncate max-w-[120px]"
title={getIndustrySectorLabel(p.sector)}
>
{getIndustrySectorLabel(p.sector)}
</span>
<span className="text-slate-500">&bull;</span>
<span
className="text-slate-400 text-[10px] truncate max-w-[120px]"
title={getProjectTypeLabel(p.projectType)}
>
{getProjectTypeLabel(p.projectType)}
</span>
</div>
</div>
</td>
{/* Parent Programme */}
<td className="py-3.5 px-3 w-36">
<div className="space-y-0.5">
<span
className="text-xs font-semibold text-slate-200 block truncate max-w-[130px]"
title={prog?.name || `Programme #${p.programmeId}`}
>
{prog?.name || `Prog #${p.programmeId}`}
</span>
<span className="text-[10px] text-slate-500 font-mono">
Programme #{p.programmeId}
</span>
</div>
</td>
{/* Schedule (Dates) */}
<td className="py-3.5 px-3 w-36">
<div className="space-y-0.5 font-mono text-[11px] text-slate-400 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-slate-300">
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span>{formatDate(p.startedAt)} &rarr; {formatDate(p.endedAt)}</span>
</div>
{p.startedAt && p.endedAt && (
<span className="text-[10px] text-slate-500 block">
{Math.max(
1,
Math.round(
(new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime()) /
(1000 * 60 * 60 * 24 * 30)
)
)}{' '}
Months Estimated Duration
</span>
)}
</div>
</td>
{/* Milestone Progress */}
<td className="py-3.5 px-3 w-28">
<div className="space-y-1 w-24">
<div className="flex justify-between text-[11px] font-semibold">
<span className={progressPct >= 100 ? 'text-emerald-400' : 'text-blue-400'}>
{progressPct}%
</span>
<span className="text-slate-500 text-[10px]">
({milestoneStats.verified}/{milestoneStats.total})
</span>
</div>
<div className="w-full h-1.5 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
className={`h-full rounded-full transition-all duration-300 ${
progressPct >= 100
? 'bg-emerald-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500'
}`}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</td>
{/* Schedule Risk Column with Icon & Tooltip */}
<td className="py-3.5 px-3 w-32" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{scheduleRisk.isOverdue ? (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold border ${scheduleRisk.badgeClass}`}
title={scheduleRisk.reason}
>
<AlertTriangle className="w-3 h-3 text-rose-400 shrink-0" />
<span>Overdue</span>
</span>
) : scheduleRisk.isAtRisk ? (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border ${scheduleRisk.badgeClass}`}
title={scheduleRisk.reason}
>
<AlertCircle className="w-3 h-3 text-amber-400 shrink-0" />
<span>At Risk ({scheduleRisk.daysRemaining}d)</span>
</span>
) : scheduleRisk.iconType === 'on_track' ? (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${scheduleRisk.badgeClass}`}
title={scheduleRisk.reason}
>
<CheckCircle2 className="w-3 h-3 text-emerald-400 shrink-0" />
<span>On Track</span>
</span>
) : (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] border ${scheduleRisk.badgeClass}`}
title={scheduleRisk.reason}
>
<Clock className="w-3 h-3 text-slate-500 shrink-0" />
<span>Scheduled</span>
</span>
)}
<HelpTooltip
title="Schedule Risk Breakdown"
content={scheduleRisk.reason}
size="sm"
placement="top"
/>
</div>
</td>
{/* Governance Status */}
<td className="py-3.5 px-3 whitespace-nowrap w-32" onClick={(e) => e.stopPropagation()}>
<div className="w-28">
{(() => {
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
const isSelf = isMaker(p.createdBy);
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
return (
<select
value={p.status}
disabled={!canModifyStatus}
onChange={(e) =>
statusMutation.mutate({
projectId: p.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveThis;
let reasonText = '';
if (isApprovedOpt && !canApprove('projects_milestones')) {
reasonText = ' (Requires Update)';
} else if (isApprovedOpt && isSelf) {
reasonText = ' (Maker-Checker)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
</div>
</td>
{/* Row Level Buttons: View Project, Milestones, Activities, Indicators, Risks, Edit */}
<td className="py-3.5 px-3 text-right whitespace-nowrap w-44" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5 flex-nowrap">
{/* Primary View Project Modal Button */}
<button
type="button"
onClick={(e) => handleOpenProjectView(p, e)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 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 transition cursor-pointer shadow-sm shadow-blue-600/25 shrink-0"
title="Open Project View & Interactive Gantt Timeline"
>
<Eye className="w-3.5 h-3.5" />
<span>View Project</span>
</button>
{/* Milestones Modal Button */}
<button
type="button"
onClick={(e) => handleOpenMilestones(p, e)}
className="inline-flex items-center gap-1 p-1.5 2xl:px-2.5 2xl:py-1.5 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
title="Manage Contractual Milestones"
>
<Flag className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="hidden 2xl:inline">Milestones</span>
</button>
{/* Activities Modal Button */}
<button
type="button"
onClick={(e) => handleOpenActivities(p, e)}
className="inline-flex items-center gap-1 p-1.5 2xl:px-2.5 2xl:py-1.5 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
title="Manage Work Breakdown Activities"
>
<Briefcase className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
<span className="hidden 2xl:inline">Activities</span>
</button>
{/* Indicators Modal Button */}
<button
type="button"
onClick={(e) => handleOpenIndicators(p, e)}
className="inline-flex items-center gap-1 p-1.5 2xl:px-2.5 2xl:py-1.5 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
title="Manage M&E Performance Indicators"
>
<Target className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
<span className="hidden 2xl:inline">Indicators</span>
</button>
{/* Risks Modal Button */}
<button
type="button"
onClick={(e) => handleOpenRisks(p, e)}
className="inline-flex items-center gap-1 p-1.5 2xl:px-2.5 2xl:py-1.5 text-[11px] font-semibold text-rose-300 hover:text-white bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
title="Manage Risk Register (ISO 31000)"
>
<ShieldAlert className="w-3.5 h-3.5 text-rose-400 shrink-0" />
<span className="hidden 2xl:inline">Risks</span>
</button>
{/* Edit Project Button */}
<button
type="button"
onClick={(e) => handleOpenEditProject(p, e)}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 border border-transparent hover:border-slate-700/60 transition cursor-pointer shrink-0"
title="Edit Project Scope & Schedule"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */}
<ProjectViewModal
isOpen={isProjectViewModalOpen}
onClose={() => {
setIsProjectViewModalOpen(false);
setViewingProject(null);
}}
project={viewingProject}
programmeMap={programmeMap}
onOpenMilestonesModal={() => {
if (viewingProject) {
setActiveModalProject(viewingProject);
setIsMilestonesModalOpen(true);
}
}}
onOpenActivitiesModal={() => {
if (viewingProject) {
setActiveModalProject(viewingProject);
setIsActivitiesModalOpen(true);
}
}}
onOpenIndicatorsModal={() => {
if (viewingProject) {
setActiveModalProject(viewingProject);
setIsIndicatorsModalOpen(true);
}
}}
onOpenRisksModal={() => {
if (viewingProject) {
setActiveModalProject(viewingProject);
setIsRisksModalOpen(true);
}
}}
onOpenEditProject={() => {
if (viewingProject) {
setEditingProject(viewingProject);
setIsProjectModalOpen(true);
}
}}
/>
{/* Project Form Modal (Create or Edit) */}
<ProjectFormModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setEditingProject(null);
}}
editingProject={editingProject}
programmes={programmes}
defaultProgrammeId={selectedProgrammeFilter !== 'all' ? selectedProgrammeFilter : undefined}
/>
{/* Milestones Management Modal */}
<ProjectMilestonesModal
isOpen={isMilestonesModalOpen}
onClose={() => {
setIsMilestonesModalOpen(false);
setActiveModalProject(null);
}}
project={activeModalProject}
/>
{/* Activities Management Modal */}
<ProjectActivitiesModal
isOpen={isActivitiesModalOpen}
onClose={() => {
setIsActivitiesModalOpen(false);
setActiveModalProject(null);
}}
project={activeModalProject}
/>
{/* Indicators Management Modal */}
<ProjectIndicatorsModal
isOpen={isIndicatorsModalOpen}
onClose={() => {
setIsIndicatorsModalOpen(false);
setActiveModalProject(null);
}}
project={activeModalProject}
/>
{/* Risks Management Modal */}
<ProjectRisksModal
isOpen={isRisksModalOpen}
onClose={() => {
setIsRisksModalOpen(false);
setActiveModalProject(null);
}}
project={activeModalProject}
/>
</div>
);
};