Light projects page refactor

This commit is contained in:
2026-09-28 11:45:05 +02:00
parent 8b68f7b99c
commit 891801c73d
2 changed files with 245 additions and 45 deletions
@@ -175,7 +175,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
</div>
{/* Timeline Visualization Surface */}
<div className="relative min-h-[220px] bg-[#090d16] rounded-xl border border-slate-800/90 p-4 overflow-x-auto">
<div className="relative min-h-[290px] bg-[#090d16] rounded-xl border border-slate-800/90 pt-8 pb-5 px-4 overflow-x-auto">
{/* Background vertical grid lines */}
<div className="absolute inset-0 pointer-events-none">
{timelineData.ticks.map((tick, idx) => (
@@ -189,13 +189,13 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
{/* Today marker */}
{timelineData.isTodayInRange && (
<div
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
className="absolute top-1 bottom-0 z-20 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.todayPercent}%` }}
>
<span className="px-1.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/40 rounded-full whitespace-nowrap -translate-y-2 shadow-sm">
<span className="px-2 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/25 border border-amber-500/50 rounded-full whitespace-nowrap shadow-md shadow-amber-500/20">
Today
</span>
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30" />
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30 mt-1" />
</div>
)}
</div>
@@ -217,11 +217,11 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
</div>
{timelineData.sortedMilestones.length === 0 ? (
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
<div className="p-4 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
No contractual milestones defined yet. Click &quot;Add / Manage Milestones&quot; to establish deliverables.
</div>
) : (
<div className="relative h-20 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
<div className="relative h-24 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
{/* Milestone connecting base line */}
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" />
@@ -5,7 +5,6 @@ import {
FolderKanban,
Plus,
Search,
Filter,
RotateCcw,
ChevronDown,
ChevronRight,
@@ -17,12 +16,10 @@ import {
Calendar,
Clock,
TrendingUp,
Layers,
FileCheck,
Building2,
CheckCircle2,
AlertTriangle,
AlertCircle,
BarChart3,
CheckCircle2,
SlidersHorizontal,
} from 'lucide-react';
import {
@@ -35,6 +32,8 @@ import {
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 { useTableSort, SortableTh } from '../utils/tableSort';
import {
@@ -62,6 +61,89 @@ import { ProjectActivitiesModal } from '../components/projects/ProjectActivities
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();
@@ -185,6 +267,39 @@ export const ProjectsPage: React.FC = () => {
});
}, [projects, searchTerm, selectedSectorFilter, selectedStatusFilter]);
// Export Columns Configuration
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
{ header: 'Project ID', accessor: (p) => `#${p.id}` },
{ header: 'Project Name', accessor: (p) => p.name || 'Untitled' },
{
header: 'Parent Programme',
accessor: (p) => programmeMap.get(p.programmeId)?.name || `Prog #${p.programmeId}`,
},
{ header: 'Industry Sector', accessor: (p) => getIndustrySectorLabel(p.sector) },
{ header: 'Project Type', accessor: (p) => getProjectTypeLabel(p.projectType) },
{ header: 'Start Date', accessor: (p) => formatDate(p.startedAt) },
{ header: 'Target End Date', accessor: (p) => formatDate(p.endedAt) },
{
header: 'Schedule Risk Alert',
accessor: (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',
accessor: (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: 'Approval Status', accessor: (p) => getApprovalStatusBadge(p.status).label },
{ header: 'Created By', accessor: (p) => getUserEmail(p.createdBy) },
], [programmeMap, milestonesByProject]);
// Table Sorting
const customExtractors = {
name: (p: Project) => (p.name || '').toLowerCase(),
@@ -198,11 +313,18 @@ export const ProjectsPage: React.FC = () => {
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,
'name',
'risk',
'asc',
customExtractors
);
@@ -277,7 +399,12 @@ export const ProjectsPage: React.FC = () => {
const verifiedMilestonesTotal = allMilestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneCompletionRate =
allMilestones.length > 0 ? Math.round((verifiedMilestonesTotal / allMilestones.length) * 100) : 0;
const highRiskCount = allRisks.filter((r) => r.likelihood * r.impact >= 12).length;
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">
@@ -312,6 +439,14 @@ export const ProjectsPage: React.FC = () => {
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
<ExportButton
title="Projects & Milestones Register"
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
filename="projects-milestones-register"
columns={projectExportColumns}
data={sortedProjects}
/>
<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"
@@ -353,6 +488,20 @@ export const ProjectsPage: React.FC = () => {
</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>
@@ -362,19 +511,7 @@ export const ProjectsPage: React.FC = () => {
<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">Impact & performance results</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">Risk Profile</span>
<ShieldAlert className="w-4 h-4 text-rose-400" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-rose-400">{allRisks.length}</span>
<span className="text-xs text-slate-400">({highRiskCount} High/Critical)</span>
</div>
<p className="text-[11px] text-slate-500 mt-1">ISO 31000 risk registers</p>
<p className="text-[11px] text-slate-500 mt-1">Results & impact metrics</p>
</div>
</div>
@@ -446,25 +583,31 @@ export const ProjectsPage: React.FC = () => {
</select>
</div>
{/* Actions: Reset & Refresh */}
<button
type="button"
onClick={resetFilters}
title="Reset all filters"
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
>
<RotateCcw className="w-4 h-4" />
</button>
{/* 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>
)}
{/* 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 data"
className={`p-1.5 rounded-xl text-blue-400 hover:text-blue-300 hover:bg-slate-800 transition cursor-pointer ${
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' : ''
}`}
>
@@ -515,8 +658,8 @@ export const ProjectsPage: React.FC = () => {
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
Funded Projects Directory ({sortedProjects.length})
</h3>
<span className="text-xs text-slate-500">
&bull; Click any row to toggle the Gantt timeline & resource details
<span className="text-xs text-slate-500 hidden sm:inline">
&bull; Click any row to expand the Gantt timeline & resource details
</span>
</div>
@@ -551,6 +694,17 @@ export const ProjectsPage: React.FC = () => {
<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>
@@ -560,7 +714,7 @@ export const ProjectsPage: React.FC = () => {
<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">
<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>
@@ -569,7 +723,7 @@ export const ProjectsPage: React.FC = () => {
</tr>
) : sortedProjects.length === 0 ? (
<tr>
<td colSpan={7} className="p-12 text-center text-slate-400">
<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>
@@ -591,13 +745,13 @@ export const ProjectsPage: React.FC = () => {
) : (
sortedProjects.map((p) => {
const isExpanded = expandedRowIds.has(p.id);
const statusBadge = getApprovalStatusBadge(p.status);
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}>
@@ -684,10 +838,10 @@ export const ProjectsPage: React.FC = () => {
{/* Milestone Progress */}
<td className="py-4 px-4">
<div className="space-y-1 w-36">
<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}% Verified
{progressPct}%
</span>
<span className="text-slate-500 text-[10px]">
({milestoneStats.verified}/{milestoneStats.total})
@@ -706,6 +860,52 @@ export const ProjectsPage: React.FC = () => {
</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">
@@ -791,7 +991,7 @@ export const ProjectsPage: React.FC = () => {
{/* Expanded View Content */}
{isExpanded && (
<tr>
<td colSpan={7} className="p-0 border-b border-blue-500/20">
<td colSpan={8} className="p-0 border-b border-blue-500/20">
<ProjectExpandedRow
project={p}
onOpenMilestonesModal={() => handleOpenMilestones(p)}