Light projects page refactor
This commit is contained in:
@@ -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 "Add / Manage Milestones" 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 */}
|
||||
{/* Clear Filter Button */}
|
||||
{(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<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">
|
||||
• Click any row to toggle the Gantt timeline & resource details
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">
|
||||
• 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)}
|
||||
|
||||
Reference in New Issue
Block a user