1114 lines
49 KiB
TypeScript
1114 lines
49 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,
|
|
} 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 { ProjectExpandedRow } from '../components/projects/ProjectExpandedRow';
|
|
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, canExport } = 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');
|
|
|
|
// Expanded Rows State (store project IDs)
|
|
const [expandedRowIds, setExpandedRowIds] = useState<Set<string | number>>(new Set());
|
|
|
|
// 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
|
|
);
|
|
|
|
// Toggle Single Row Expansion
|
|
const toggleRow = (id: string | number) => {
|
|
setExpandedRowIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Expand / Collapse All Rows
|
|
const toggleAllRows = () => {
|
|
if (expandedRowIds.size === sortedProjects.length && sortedProjects.length > 0) {
|
|
setExpandedRowIds(new Set());
|
|
} else {
|
|
setExpandedRowIds(new Set(sortedProjects.map((p) => p.id)));
|
|
}
|
|
};
|
|
|
|
// 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 max-w-7xl 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: "{searchTerm}"
|
|
</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-500 hidden sm:inline">
|
|
• Click any row to expand the Gantt timeline & resource details
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={toggleAllRows}
|
|
className="text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/80 px-3 py-1.5 rounded-xl transition cursor-pointer"
|
|
>
|
|
{expandedRowIds.size === sortedProjects.length && sortedProjects.length > 0
|
|
? 'Collapse All'
|
|
: 'Expand All'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projects Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs text-slate-300 border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-800/80 bg-[#090d16] text-[11px] font-semibold text-slate-400 select-none">
|
|
<th className="py-3 px-3 w-10 text-center"></th>
|
|
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
|
Project Name & ID
|
|
</SortableTh>
|
|
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
|
Parent Programme
|
|
</SortableTh>
|
|
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
|
Schedule (Timeline)
|
|
</SortableTh>
|
|
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
|
WBS Progress
|
|
</SortableTh>
|
|
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
|
<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-4">
|
|
Status
|
|
</SortableTh>
|
|
<th className="py-3 px-4 text-right font-semibold">Resource Management Hub</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60 font-medium">
|
|
{loadingProjects ? (
|
|
<tr>
|
|
<td colSpan={8} 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={8} 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 isExpanded = expandedRowIds.has(p.id);
|
|
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 (
|
|
<React.Fragment key={p.id}>
|
|
{/* Main Project Row */}
|
|
<tr
|
|
onClick={() => toggleRow(p.id)}
|
|
className={`transition-colors cursor-pointer group ${
|
|
isExpanded
|
|
? 'bg-[#0f172a]/90 text-white'
|
|
: 'hover:bg-slate-900/60 text-slate-300'
|
|
}`}
|
|
>
|
|
{/* Expand/Collapse Chevron */}
|
|
<td className="py-4 px-3 text-center">
|
|
<button
|
|
type="button"
|
|
aria-label="Expand project row"
|
|
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-4 h-4 text-blue-400" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 group-hover:text-white" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
|
|
{/* Project Name & Meta */}
|
|
<td className="py-4 px-4">
|
|
<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">
|
|
{p.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 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">
|
|
#{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]">
|
|
{getIndustrySectorLabel(p.sector)}
|
|
</span>
|
|
<span className="text-slate-500">•</span>
|
|
<span className="text-slate-400 text-[10px]">
|
|
{getProjectTypeLabel(p.projectType)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Parent Programme */}
|
|
<td className="py-4 px-4">
|
|
<div className="space-y-0.5">
|
|
<span className="text-xs font-semibold text-slate-200 block truncate max-w-[160px]">
|
|
{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-4 px-4">
|
|
<div className="space-y-0.5 font-mono text-[11px] text-slate-400">
|
|
<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)} → {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-4 px-4">
|
|
<div className="space-y-1 w-32">
|
|
<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-4 px-4" 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-4 px-4" onClick={(e) => e.stopPropagation()}>
|
|
<div className="space-y-1">
|
|
<select
|
|
value={p.status}
|
|
onChange={(e) =>
|
|
statusMutation.mutate({
|
|
projectId: p.id,
|
|
status: Number(e.target.value) as ApprovalStatus,
|
|
})
|
|
}
|
|
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
|
>
|
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Row Level Buttons: Milestones, Activities, Indicators, Risks, Edit */}
|
|
<td className="py-4 px-4 text-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-1.5 flex-wrap">
|
|
{/* Milestones Modal Button */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleOpenMilestones(p, e)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 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-lg transition cursor-pointer shadow-sm"
|
|
title="Manage Contractual Milestones"
|
|
>
|
|
<Flag className="w-3 h-3 text-blue-400" />
|
|
<span>Milestones</span>
|
|
</button>
|
|
|
|
{/* Activities Modal Button */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleOpenActivities(p, e)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 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-lg transition cursor-pointer shadow-sm"
|
|
title="Manage Work Breakdown Activities"
|
|
>
|
|
<Briefcase className="w-3 h-3 text-cyan-400" />
|
|
<span>Activities</span>
|
|
</button>
|
|
|
|
{/* Indicators Modal Button */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleOpenIndicators(p, e)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 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-lg transition cursor-pointer shadow-sm"
|
|
title="Manage M&E Performance Indicators"
|
|
>
|
|
<Target className="w-3 h-3 text-indigo-400" />
|
|
<span>Indicators</span>
|
|
</button>
|
|
|
|
{/* Risks Modal Button */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleOpenRisks(p, e)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 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-lg transition cursor-pointer shadow-sm"
|
|
title="Manage Risk Register (ISO 31000)"
|
|
>
|
|
<ShieldAlert className="w-3 h-3 text-rose-400" />
|
|
<span>Risks</span>
|
|
</button>
|
|
|
|
{/* Edit Project Button */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleOpenEditProject(p, e)}
|
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
title="Edit Project Scope & Schedule"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded View Content */}
|
|
{isExpanded && (
|
|
<tr>
|
|
<td colSpan={8} className="p-0 border-b border-blue-500/20">
|
|
<ProjectExpandedRow
|
|
project={p}
|
|
onOpenMilestonesModal={() => handleOpenMilestones(p)}
|
|
onOpenActivitiesModal={() => handleOpenActivities(p)}
|
|
onOpenIndicatorsModal={() => handleOpenIndicators(p)}
|
|
onOpenRisksModal={() => handleOpenRisks(p)}
|
|
onOpenEditProject={() => handleOpenEditProject(p)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
);
|
|
};
|