Merge pull request 'Refactored project view into modal' (#148) from finishline into main
Reviewed-on: #148
This commit was merged in pull request #148.
This commit is contained in:
@@ -91,21 +91,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 sm:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200">
|
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||||
{/* Project Meta Bar */}
|
{/* Project Meta Bar */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800 min-w-0">
|
||||||
<div className="space-y-1 min-w-0 flex-1">
|
<div className="space-y-1 min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
|
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30 shrink-0">
|
||||||
Project #{project.id} Summary
|
Project #{project.id} Summary
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400 truncate">
|
||||||
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
||||||
</span>
|
</span>
|
||||||
{approvedBudgetTotal > 0 && (
|
{approvedBudgetTotal > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-slate-600">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<span className="text-xs text-emerald-400 font-mono font-semibold">
|
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -114,10 +114,10 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0 self-start md:self-center flex-wrap">
|
<div className="flex items-center gap-2 shrink-0 self-start lg:self-center flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs 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"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs 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 shrink-0"
|
||||||
>
|
>
|
||||||
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Review Evidence Queue
|
Review Evidence Queue
|
||||||
@@ -125,7 +125,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenEditProject}
|
onClick={onOpenEditProject}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shrink-0"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
||||||
Edit Project Scope
|
Edit Project Scope
|
||||||
@@ -145,9 +145,9 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 4 Vital Resource Summary Cards Grid */}
|
{/* 4 Vital Resource Summary Cards Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 min-w-0">
|
||||||
{/* Card 1: Milestones */}
|
{/* Card 1: Milestones */}
|
||||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
@@ -198,7 +198,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 2: Activities */}
|
{/* Card 2: Activities */}
|
||||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
@@ -244,7 +244,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 3: Indicators */}
|
{/* Card 3: Indicators */}
|
||||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
@@ -297,7 +297,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 4: Risks */}
|
{/* Card 4: Risks */}
|
||||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Flag,
|
Flag,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -6,9 +6,15 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
TrendingUp,
|
|
||||||
Plus,
|
Plus,
|
||||||
Coins,
|
Coins,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Compass,
|
||||||
|
ArrowRight,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types';
|
import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types';
|
||||||
import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters';
|
import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters';
|
||||||
@@ -32,7 +38,12 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
onManageMilestones,
|
onManageMilestones,
|
||||||
onManageActivities,
|
onManageActivities,
|
||||||
}) => {
|
}) => {
|
||||||
// Compute timeline boundaries and items
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<'compact' | 'standard' | 'expanded'>('standard');
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
// Compute timeline boundaries, first/last milestones, and ruler ticks
|
||||||
const timelineData = useMemo(() => {
|
const timelineData = useMemo(() => {
|
||||||
const dates: number[] = [];
|
const dates: number[] = [];
|
||||||
|
|
||||||
@@ -56,14 +67,14 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
|
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
|
||||||
startMs = Math.min(...dates);
|
startMs = Math.min(...dates);
|
||||||
endMs = Math.max(...dates);
|
endMs = Math.max(...dates);
|
||||||
// Add a slight buffer (5% on each side) for aesthetic padding
|
// Aesthetic buffer (6% on each side, minimum 10 days)
|
||||||
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 24 * 60 * 60 * 1000);
|
const buffer = Math.max((endMs - startMs) * 0.06, 10 * 24 * 60 * 60 * 1000);
|
||||||
startMs -= buffer;
|
startMs -= buffer;
|
||||||
endMs += buffer;
|
endMs += buffer;
|
||||||
} else {
|
} else {
|
||||||
// Default: current quarter +/- 3 months
|
// Default fallback
|
||||||
startMs = now - 90 * 24 * 60 * 60 * 1000;
|
startMs = now - 60 * 24 * 60 * 60 * 1000;
|
||||||
endMs = now + 180 * 24 * 60 * 60 * 1000;
|
endMs = now + 240 * 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalDurationMs = endMs - startMs;
|
const totalDurationMs = endMs - startMs;
|
||||||
@@ -74,28 +85,36 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
return ((clamped - startMs) / totalDurationMs) * 100;
|
return ((clamped - startMs) / totalDurationMs) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate monthly ticks for ruler
|
// Monthly ruler ticks
|
||||||
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = [];
|
const ticks: { label: string; yearLabel?: string; percent: number; isStartOfYear: boolean; isQuarter: boolean }[] = [];
|
||||||
const currDate = new Date(startMs);
|
const currDate = new Date(startMs);
|
||||||
currDate.setDate(1);
|
currDate.setDate(1);
|
||||||
currDate.setHours(0, 0, 0, 0);
|
currDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
while (currDate.getTime() <= endMs) {
|
while (currDate.getTime() <= endMs) {
|
||||||
if (currDate.getTime() >= startMs) {
|
if (currDate.getTime() >= startMs) {
|
||||||
|
const monthNum = currDate.getMonth();
|
||||||
ticks.push({
|
ticks.push({
|
||||||
label: currDate.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }),
|
label: currDate.toLocaleDateString('en-GB', { month: 'short' }),
|
||||||
|
yearLabel: currDate.getFullYear().toString(),
|
||||||
percent: getPercent(currDate.getTime()),
|
percent: getPercent(currDate.getTime()),
|
||||||
isStartOfYear: currDate.getMonth() === 0,
|
isStartOfYear: monthNum === 0,
|
||||||
|
isQuarter: monthNum % 3 === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
currDate.setMonth(currDate.getMonth() + 1);
|
currDate.setMonth(currDate.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort milestones & activities by date
|
// Sort milestones chronologically
|
||||||
const sortedMilestones = [...milestones].sort(
|
const sortedMilestones = [...milestones].sort(
|
||||||
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
|
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Identify first and last milestones
|
||||||
|
const firstMilestone = sortedMilestones.length > 0 ? sortedMilestones[0] : null;
|
||||||
|
const lastMilestone = sortedMilestones.length > 0 ? sortedMilestones[sortedMilestones.length - 1] : null;
|
||||||
|
|
||||||
|
// Sort activities chronologically
|
||||||
const sortedActivities = [...activities].sort(
|
const sortedActivities = [...activities].sort(
|
||||||
(a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime()
|
(a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime()
|
||||||
);
|
);
|
||||||
@@ -103,6 +122,9 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
const todayPercent = getPercent(now);
|
const todayPercent = getPercent(now);
|
||||||
const isTodayInRange = todayPercent >= 0 && todayPercent <= 100;
|
const isTodayInRange = todayPercent >= 0 && todayPercent <= 100;
|
||||||
|
|
||||||
|
const projectStartPercent = project.startedAt ? getPercent(new Date(project.startedAt).getTime()) : null;
|
||||||
|
const projectEndPercent = project.endedAt ? getPercent(new Date(project.endedAt).getTime()) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startMs,
|
startMs,
|
||||||
endMs,
|
endMs,
|
||||||
@@ -110,321 +132,608 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
getPercent,
|
getPercent,
|
||||||
ticks,
|
ticks,
|
||||||
sortedMilestones,
|
sortedMilestones,
|
||||||
|
firstMilestone,
|
||||||
|
lastMilestone,
|
||||||
sortedActivities,
|
sortedActivities,
|
||||||
todayPercent,
|
todayPercent,
|
||||||
isTodayInRange,
|
isTodayInRange,
|
||||||
|
projectStartPercent,
|
||||||
|
projectEndPercent,
|
||||||
};
|
};
|
||||||
}, [project, milestones, activities]);
|
}, [project, milestones, activities]);
|
||||||
|
|
||||||
|
// Check scroll capability
|
||||||
|
const updateScrollState = () => {
|
||||||
|
const el = scrollContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 10);
|
||||||
|
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = scrollContainerRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('scroll', updateScrollState);
|
||||||
|
window.addEventListener('resize', updateScrollState);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (el) el.removeEventListener('scroll', updateScrollState);
|
||||||
|
window.removeEventListener('resize', updateScrollState);
|
||||||
|
};
|
||||||
|
}, [timelineData, zoomLevel]);
|
||||||
|
|
||||||
|
// Horizontal shift handlers (requirement 4)
|
||||||
|
const handleScrollLeft = () => {
|
||||||
|
if (!scrollContainerRef.current) return;
|
||||||
|
scrollContainerRef.current.scrollBy({ left: -360, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollRight = () => {
|
||||||
|
if (!scrollContainerRef.current) return;
|
||||||
|
scrollContainerRef.current.scrollBy({ left: 360, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick jump handlers (requirement 5)
|
||||||
|
const scrollToPercent = (percent: number) => {
|
||||||
|
const el = scrollContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const totalWidth = el.scrollWidth;
|
||||||
|
const clientWidth = el.clientWidth;
|
||||||
|
const targetScrollLeft = (percent / 100) * totalWidth - clientWidth / 2;
|
||||||
|
el.scrollTo({ left: Math.max(0, targetScrollLeft), behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToMilestone = (m: Milestone) => {
|
||||||
|
if (!m.dueAt) return;
|
||||||
|
const pct = timelineData.getPercent(new Date(m.dueAt).getTime());
|
||||||
|
scrollToPercent(pct);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToToday = () => {
|
||||||
|
if (timelineData.isTodayInRange) {
|
||||||
|
scrollToPercent(timelineData.todayPercent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToProjectStart = () => {
|
||||||
|
if (timelineData.projectStartPercent !== null) {
|
||||||
|
scrollToPercent(timelineData.projectStartPercent);
|
||||||
|
} else if (timelineData.firstMilestone) {
|
||||||
|
scrollToMilestone(timelineData.firstMilestone);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToProjectEnd = () => {
|
||||||
|
if (timelineData.projectEndPercent !== null) {
|
||||||
|
scrollToPercent(timelineData.projectEndPercent);
|
||||||
|
} else if (timelineData.lastMilestone) {
|
||||||
|
scrollToMilestone(timelineData.lastMilestone);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zoom width classes
|
||||||
|
const canvasWidthClass =
|
||||||
|
zoomLevel === 'compact'
|
||||||
|
? 'min-w-[1200px]'
|
||||||
|
: zoomLevel === 'standard'
|
||||||
|
? 'min-w-[1900px]'
|
||||||
|
: 'min-w-[2800px]';
|
||||||
|
|
||||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner">
|
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-4 sm:p-5 space-y-4 shadow-xl w-full min-w-0 relative">
|
||||||
{/* Timeline Header & Key Stats */}
|
{/* Timeline Header, Key Stats & Enhanced Navigation Bar */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80">
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-3.5 pb-4 border-b border-slate-800/80 min-w-0">
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
{/* Title & Span Info */}
|
||||||
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<Calendar className="w-4 h-4" />
|
<div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-blue-600/20 to-indigo-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-md shadow-blue-500/10 shrink-0">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
Implementation Schedule & Timeline
|
<h4 className="text-sm font-bold text-white tracking-wide uppercase">
|
||||||
<span className="text-[10px] normal-case font-normal text-slate-400">
|
Implementation Schedule & Timeline Gantt
|
||||||
({timelineData.totalDurationDays} Days Span)
|
</h4>
|
||||||
|
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||||
|
{timelineData.totalDurationDays} Days Span
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
{project.startedAt && project.endedAt && (
|
||||||
<p className="text-[11px] text-slate-400">
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
Chronological alignment of contractual deliverables and operational activities.
|
({formatDate(project.startedAt)} → {formatDate(project.endedAt)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5 truncate">
|
||||||
|
Chronological alignment of contractual deliverables, work breakdown activities, and target milestones.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Legend & Actions */}
|
{/* Enhanced Navigation Toolbar (Quick Jump, Zoom, Scroll Controls) */}
|
||||||
<div className="flex items-center gap-2 text-[11px] flex-wrap shrink-0">
|
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||||
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400 flex-wrap">
|
{/* Quick Jump Buttons (Requirement 5) */}
|
||||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
<div className="flex items-center gap-1.5 p-1 bg-slate-900/90 rounded-xl border border-slate-800 text-xs flex-wrap">
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" />
|
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5 hidden sm:inline">Jump:</span>
|
||||||
Verified Deliverable
|
|
||||||
</span>
|
{/* Jump to First Milestone */}
|
||||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
{timelineData.firstMilestone ? (
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" />
|
<button
|
||||||
Pending / Review
|
type="button"
|
||||||
</span>
|
onClick={() => scrollToMilestone(timelineData.firstMilestone!)}
|
||||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 transition cursor-pointer shadow-xs"
|
||||||
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
|
title={`Jump to First Milestone: "${timelineData.firstMilestone.name}" (${formatDate(timelineData.firstMilestone.dueAt)})`}
|
||||||
Work Activity
|
>
|
||||||
</span>
|
<Flag className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span className="max-w-[120px] truncate">
|
||||||
|
Start: {timelineData.firstMilestone.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : project.startedAt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToProjectStart}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||||
|
title={`Jump to Project Start (${formatDate(project.startedAt)})`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-3 h-3 text-blue-400" />
|
||||||
|
<span>Start Date</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Jump to Today */}
|
||||||
|
{timelineData.isTodayInRange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToToday}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-amber-500/15 hover:bg-amber-500/25 text-amber-300 border border-amber-500/40 transition cursor-pointer shadow-xs"
|
||||||
|
title="Jump directly to current date on timeline"
|
||||||
|
>
|
||||||
|
<Clock className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>Today</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jump to Last Milestone */}
|
||||||
|
{timelineData.lastMilestone ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollToMilestone(timelineData.lastMilestone!)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 border border-indigo-500/30 transition cursor-pointer shadow-xs"
|
||||||
|
title={`Jump to Final Milestone: "${timelineData.lastMilestone.name}" (${formatDate(timelineData.lastMilestone.dueAt)})`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-indigo-400" />
|
||||||
|
<span className="max-w-[120px] truncate">
|
||||||
|
Final: {timelineData.lastMilestone.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : project.endedAt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToProjectEnd}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||||
|
title={`Jump to Project Target End (${formatDate(project.endedAt)})`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-3 h-3 text-indigo-400" />
|
||||||
|
<span>Target End</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom Level Selector */}
|
||||||
|
<div className="flex items-center p-0.5 bg-slate-900/90 rounded-xl border border-slate-800 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomLevel('compact')}
|
||||||
|
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||||
|
zoomLevel === 'compact'
|
||||||
|
? 'bg-blue-600 text-white shadow-xs'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Compact View (Fits broader duration)"
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomLevel('standard')}
|
||||||
|
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||||
|
zoomLevel === 'standard'
|
||||||
|
? 'bg-blue-600 text-white shadow-xs'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Standard View (Balanced spacing)"
|
||||||
|
>
|
||||||
|
Standard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomLevel('expanded')}
|
||||||
|
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||||
|
zoomLevel === 'expanded'
|
||||||
|
? 'bg-blue-600 text-white shadow-xs'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Expanded View (Deep detail, optimal for scrolling)"
|
||||||
|
>
|
||||||
|
Expanded
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Shift Buttons in Top Header */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScrollLeft}
|
||||||
|
disabled={!canScrollLeft}
|
||||||
|
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Shift timeline left (360px)"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScrollRight}
|
||||||
|
disabled={!canScrollRight}
|
||||||
|
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Shift timeline right (360px)"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Gantt Timeline Track Container */}
|
{/* Milestone & Activity Legend & Milestone Span Pill */}
|
||||||
<div className="space-y-4 pt-1">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 text-xs text-slate-400 flex-wrap">
|
||||||
{/* Month Scale / Ruler */}
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="relative h-6 w-full border-b border-slate-800 text-[10px] font-mono text-slate-500 select-none">
|
<span className="flex items-center gap-1.5">
|
||||||
{timelineData.ticks.map((tick, idx) => (
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50 inline-block" />
|
||||||
<div
|
<strong className="text-slate-300">Verified Milestone</strong>
|
||||||
key={idx}
|
</span>
|
||||||
className="absolute transform -translate-x-1/2 flex flex-col items-center"
|
<span className="flex items-center gap-1.5">
|
||||||
style={{ left: `${tick.percent}%` }}
|
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm shadow-amber-500/50 inline-block" />
|
||||||
>
|
<strong className="text-slate-300">Pending Review</strong>
|
||||||
<span className={tick.isStartOfYear ? 'text-blue-400 font-bold' : 'text-slate-400'}>
|
</span>
|
||||||
{tick.label}
|
<span className="flex items-center gap-1.5">
|
||||||
</span>
|
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50 inline-block" />
|
||||||
<span className="h-1.5 w-px bg-slate-700 mt-0.5" />
|
<strong className="text-slate-300">Contractual Deliverable</strong>
|
||||||
</div>
|
</span>
|
||||||
))}
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-sm bg-cyan-500 shadow-sm shadow-cyan-500/50 inline-block" />
|
||||||
|
<strong className="text-slate-300">Work Activity</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Visualization Surface */}
|
{/* First & Last Milestone Span Pill (Requirement 5) */}
|
||||||
<div className="relative min-h-[290px] bg-[#090d16] rounded-xl border border-slate-800/90 pt-8 pb-5 px-4 overflow-x-auto">
|
{timelineData.firstMilestone && timelineData.lastMilestone && (
|
||||||
{/* Background vertical grid lines */}
|
<div className="flex items-center gap-1.5 text-[11px] font-mono bg-slate-900/90 px-3 py-1 rounded-full border border-slate-800 text-slate-300">
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<span className="text-emerald-400 font-semibold">1st: {formatDate(timelineData.firstMilestone.dueAt)}</span>
|
||||||
{timelineData.ticks.map((tick, idx) => (
|
<ArrowRight className="w-3 h-3 text-slate-500" />
|
||||||
<div
|
<span className="text-indigo-400 font-semibold">Final: {formatDate(timelineData.lastMilestone.dueAt)}</span>
|
||||||
key={idx}
|
|
||||||
className="absolute top-0 bottom-0 w-px bg-slate-800/40"
|
|
||||||
style={{ left: `${tick.percent}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Today marker */}
|
|
||||||
{timelineData.isTodayInRange && (
|
|
||||||
<div
|
|
||||||
className="absolute top-1 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
|
||||||
style={{ left: `${timelineData.todayPercent}%` }}
|
|
||||||
>
|
|
||||||
<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 mt-1" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Layer 1: Milestones Track */}
|
{/* Scrollable Timeline Visualization Container */}
|
||||||
<div className="relative z-10 mb-6 space-y-2">
|
<div className="relative group/timeline rounded-2xl border border-slate-800/90 bg-[#070b13] overflow-hidden shadow-2xl">
|
||||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
{/* Scrollable Viewport */}
|
||||||
<span className="flex items-center gap-1.5 text-blue-400">
|
<div
|
||||||
<Flag className="w-3.5 h-3.5" />
|
ref={scrollContainerRef}
|
||||||
Contractual Deliverables & Milestones ({milestones.length})
|
className="overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900 scroll-smooth w-full select-none"
|
||||||
</span>
|
>
|
||||||
<button
|
{/* Inner Canvas with expansive width */}
|
||||||
type="button"
|
<div className={`relative pt-7 pb-14 px-6 min-h-[340px] ${canvasWidthClass}`}>
|
||||||
onClick={onManageMilestones}
|
{/* Background vertical month grid lines */}
|
||||||
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
>
|
{timelineData.ticks.map((tick, idx) => (
|
||||||
<Plus className="w-3 h-3" /> Add / Manage Milestones
|
<div
|
||||||
</button>
|
key={idx}
|
||||||
|
className={`absolute top-0 bottom-0 w-px ${
|
||||||
|
tick.isStartOfYear
|
||||||
|
? 'bg-blue-500/30 border-dashed border-l border-blue-500/40'
|
||||||
|
: tick.isQuarter
|
||||||
|
? 'bg-slate-700/50'
|
||||||
|
: 'bg-slate-800/40'
|
||||||
|
}`}
|
||||||
|
style={{ left: `${tick.percent}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Project Start Line Marker */}
|
||||||
|
{timelineData.projectStartPercent !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||||
|
style={{ left: `${timelineData.projectStartPercent}%` }}
|
||||||
|
>
|
||||||
|
<span className="px-2 py-0.5 text-[9px] font-bold text-blue-300 bg-blue-600/30 border border-blue-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||||
|
Start: {formatDate(project.startedAt)}
|
||||||
|
</span>
|
||||||
|
<div className="w-px h-full bg-blue-500/50 border-dashed border-l border-blue-400 mt-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Target End Line Marker */}
|
||||||
|
{timelineData.projectEndPercent !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||||
|
style={{ left: `${timelineData.projectEndPercent}%` }}
|
||||||
|
>
|
||||||
|
<span className="px-2 py-0.5 text-[9px] font-bold text-indigo-300 bg-indigo-600/30 border border-indigo-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||||
|
End: {formatDate(project.endedAt)}
|
||||||
|
</span>
|
||||||
|
<div className="w-px h-full bg-indigo-500/50 border-dashed border-l border-indigo-400 mt-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Today line marker */}
|
||||||
|
{timelineData.isTodayInRange && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||||
|
style={{ left: `${timelineData.todayPercent}%` }}
|
||||||
|
>
|
||||||
|
<span className="px-2.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/30 border border-amber-400/60 rounded-full whitespace-nowrap shadow-lg shadow-amber-500/25">
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
<div className="w-px h-full bg-amber-400 border-l border-amber-400 shadow-md shadow-amber-400/40 mt-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{timelineData.sortedMilestones.length === 0 ? (
|
{/* Month Scale / Ruler */}
|
||||||
<div className="p-4 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
<div className="relative h-9 border-b border-slate-800 text-[11px] font-mono text-slate-400 mb-6">
|
||||||
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
|
{timelineData.ticks.map((tick, idx) => (
|
||||||
</div>
|
<div
|
||||||
) : (
|
key={idx}
|
||||||
<div className="relative h-24 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
|
className="absolute transform -translate-x-1/2 flex flex-col items-center"
|
||||||
{/* Milestone connecting base line */}
|
style={{ left: `${tick.percent}%` }}
|
||||||
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" />
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{/* Milestone Pins */}
|
<span
|
||||||
{timelineData.sortedMilestones.map((m, idx) => {
|
className={
|
||||||
const mTime = new Date(m.dueAt || 0).getTime();
|
tick.isStartOfYear
|
||||||
const pos = timelineData.getPercent(mTime);
|
? 'text-blue-400 font-bold text-xs'
|
||||||
const isApproved = m.status === ApprovalStatus.Approved;
|
: tick.isQuarter
|
||||||
const isPending = m.status === ApprovalStatus.Pending;
|
? 'text-slate-200 font-semibold'
|
||||||
const priBadge = getPriorityBadge(m.priority);
|
: 'text-slate-400'
|
||||||
|
}
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={m.id || idx}
|
|
||||||
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
|
|
||||||
style={{ left: `${Math.min(96, Math.max(4, pos))}%` }}
|
|
||||||
onClick={onManageMilestones}
|
|
||||||
>
|
>
|
||||||
{/* Flag Marker */}
|
{tick.label}
|
||||||
|
</span>
|
||||||
|
{tick.isStartOfYear && (
|
||||||
|
<span className="text-[9px] text-blue-300 font-mono bg-blue-500/20 px-1 rounded">
|
||||||
|
{tick.yearLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="h-2 w-px bg-slate-700 mt-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer 1: Contractual Deliverables & Milestones Track */}
|
||||||
|
<div className="relative z-10 mb-8 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||||
|
<span className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Flag className="w-4 h-4 text-blue-400" />
|
||||||
|
Contractual Deliverables & Milestones ({milestones.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManageMilestones}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add / Manage Milestones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{timelineData.sortedMilestones.length === 0 ? (
|
||||||
|
<div className="p-6 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||||
|
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-28 bg-slate-900/60 rounded-2xl border border-slate-800/80 flex items-center px-4 shadow-inner">
|
||||||
|
{/* Base connecting line */}
|
||||||
|
<div className="absolute left-4 right-4 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
{/* Visual progress overlay */}
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-500 to-blue-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${milestoneProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Pins */}
|
||||||
|
{timelineData.sortedMilestones.map((m, idx) => {
|
||||||
|
const mTime = new Date(m.dueAt || 0).getTime();
|
||||||
|
const pos = timelineData.getPercent(mTime);
|
||||||
|
const isApproved = m.status === ApprovalStatus.Approved;
|
||||||
|
const isPending = m.status === ApprovalStatus.Pending;
|
||||||
|
const isFirst = timelineData.firstMilestone?.id === m.id;
|
||||||
|
const isLast = timelineData.lastMilestone?.id === m.id && timelineData.sortedMilestones.length > 1;
|
||||||
|
const priBadge = getPriorityBadge(m.priority);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-8 w-8 rounded-full flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
key={m.id || idx}
|
||||||
isApproved
|
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
|
||||||
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300 shadow-emerald-500/20'
|
style={{ left: `${Math.min(97, Math.max(3, pos))}%` }}
|
||||||
: isPending
|
onClick={onManageMilestones}
|
||||||
? 'bg-amber-500/20 border-amber-400 text-amber-300 shadow-amber-500/20'
|
|
||||||
: 'bg-blue-500/20 border-blue-400 text-blue-300 shadow-blue-500/20'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Flag className="w-3.5 h-3.5 fill-current" />
|
{/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */}
|
||||||
</div>
|
{isFirst && (
|
||||||
|
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/25 border border-emerald-400 text-emerald-300 shadow-md flex items-center gap-1">
|
||||||
|
<Sparkles className="w-2.5 h-2.5 text-emerald-400" />
|
||||||
|
1st Milestone
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLast && !isFirst && (
|
||||||
|
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-indigo-500/25 border border-indigo-400 text-indigo-300 shadow-md flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-2.5 h-2.5 text-indigo-400" />
|
||||||
|
Final Close-out
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Brief Pin Label */}
|
{/* Milestone Flag Icon Bubble */}
|
||||||
<div className="mt-1 text-center whitespace-nowrap">
|
<div
|
||||||
<span className="text-[10px] font-bold text-slate-300 block max-w-[110px] truncate">
|
className={`h-9 w-9 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
||||||
{m.name}
|
isApproved
|
||||||
</span>
|
? 'bg-emerald-500/25 border-emerald-400 text-emerald-300 shadow-emerald-500/30'
|
||||||
<span className="text-[9px] text-slate-500 font-mono">
|
: isPending
|
||||||
{formatDate(m.dueAt)}
|
? 'bg-amber-500/25 border-amber-400 text-amber-300 shadow-amber-500/30'
|
||||||
</span>
|
: 'bg-blue-500/25 border-blue-400 text-blue-300 shadow-blue-500/30'
|
||||||
</div>
|
} ${isFirst ? 'ring-2 ring-emerald-400/50' : ''} ${isLast ? 'ring-2 ring-indigo-400/50' : ''}`}
|
||||||
|
>
|
||||||
{/* Tooltip on Hover */}
|
<Flag className="w-4 h-4 fill-current" />
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:flex flex-col w-56 p-2.5 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl text-left z-30 pointer-events-none">
|
|
||||||
<div className="flex items-center justify-between gap-1 mb-1">
|
|
||||||
<span className="text-[10px] font-mono text-blue-400">Milestone #{m.id}</span>
|
|
||||||
<span className={`text-[9px] px-1.5 py-0.2 rounded border ${priBadge.className}`}>
|
|
||||||
{priBadge.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-white leading-tight mb-1">{m.name}</p>
|
|
||||||
<div className="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-800">
|
{/* Deliverable Label */}
|
||||||
<span className="flex items-center gap-1">
|
<div className="mt-1 text-center whitespace-nowrap">
|
||||||
<Clock className="w-3 h-3 text-slate-500" />
|
<span className="text-[11px] font-bold text-slate-200 block max-w-[130px] truncate group-hover:text-white">
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono block">
|
||||||
{formatDate(m.dueAt)}
|
{formatDate(m.dueAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}>
|
</div>
|
||||||
{isApproved ? 'Verified' : 'Pending'}
|
|
||||||
</span>
|
{/* Rich Hover Card */}
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 hidden group-hover:flex flex-col w-64 p-3 bg-slate-900 border border-slate-700/90 rounded-2xl shadow-2xl text-left z-30 pointer-events-none backdrop-blur-md">
|
||||||
|
<div className="flex items-center justify-between gap-1 mb-1.5">
|
||||||
|
<span className="text-[10px] font-mono text-blue-400 font-semibold">
|
||||||
|
Milestone #{m.id}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] px-2 py-0.5 rounded border ${priBadge.className}`}>
|
||||||
|
{priBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold text-white leading-tight mb-2">{m.name}</p>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-2 border-t border-slate-800">
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{formatDate(m.dueAt)}
|
||||||
|
</span>
|
||||||
|
<span className={isApproved ? 'text-emerald-400 font-bold' : 'text-amber-400 font-semibold'}>
|
||||||
|
{isApproved ? '✓ Verified' : '● Pending Review'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layer 2: Activities Track */}
|
|
||||||
<div className="relative z-10 space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
|
||||||
<span className="flex items-center gap-1.5 text-cyan-400">
|
|
||||||
<Briefcase className="w-3.5 h-3.5" />
|
|
||||||
Work Breakdown Activities ({activities.length})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onManageActivities}
|
|
||||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" /> Add / Manage Activities
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{timelineData.sortedActivities.length === 0 ? (
|
{/* Layer 2: Work Breakdown Activities Track */}
|
||||||
<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="relative z-10 space-y-2.5">
|
||||||
No activities recorded in work breakdown structure.
|
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||||
|
<span className="flex items-center gap-2 text-cyan-400">
|
||||||
|
<Briefcase className="w-4 h-4 text-cyan-400" />
|
||||||
|
Work Breakdown Activities ({activities.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManageActivities}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add / Manage Activities
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{timelineData.sortedActivities.slice(0, 4).map((act, idx) => {
|
|
||||||
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
|
|
||||||
const pos = timelineData.getPercent(aTime);
|
|
||||||
|
|
||||||
return (
|
{timelineData.sortedActivities.length === 0 ? (
|
||||||
<div
|
<div className="p-4 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||||
key={act.id || idx}
|
No activities recorded in work breakdown structure.
|
||||||
className="group relative flex items-center p-2 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
</div>
|
||||||
onClick={onManageActivities}
|
) : (
|
||||||
>
|
<div className="space-y-2">
|
||||||
<div className="w-48 shrink-0 pr-3 border-r border-slate-800 truncate">
|
{timelineData.sortedActivities.slice(0, 5).map((act, idx) => {
|
||||||
<span className="text-xs font-semibold text-white block truncate">{act.title}</span>
|
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
|
||||||
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
|
const pos = timelineData.getPercent(aTime);
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 relative h-6 mx-3 flex items-center">
|
return (
|
||||||
<div className="w-full h-1 bg-slate-800 rounded-full" />
|
<div
|
||||||
<div
|
key={act.id || idx}
|
||||||
className="absolute h-4 rounded-md bg-cyan-500/20 border border-cyan-400/60 px-2 flex items-center text-[9px] font-bold text-cyan-300 shadow-sm shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
className="group relative flex items-center p-2.5 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
||||||
style={{ left: `${Math.min(94, Math.max(6, pos))}%` }}
|
onClick={onManageActivities}
|
||||||
>
|
>
|
||||||
<Coins className="w-2.5 h-2.5 mr-1 text-cyan-400" />
|
<div className="w-56 shrink-0 pr-3 border-r border-slate-800 truncate">
|
||||||
{formatZAR(act.amount)}
|
<span className="text-xs font-semibold text-white block truncate group-hover:text-cyan-300 transition">
|
||||||
|
{act.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative h-7 mx-4 flex items-center">
|
||||||
|
<div className="w-full h-1 bg-slate-800 rounded-full" />
|
||||||
|
<div
|
||||||
|
className="absolute h-5 rounded-lg bg-cyan-500/20 border border-cyan-400/60 px-2.5 flex items-center text-[10px] font-bold text-cyan-300 shadow-md shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
||||||
|
style={{ left: `${Math.min(95, Math.max(5, pos))}%` }}
|
||||||
|
>
|
||||||
|
<Coins className="w-3 h-3 mr-1 text-cyan-400" />
|
||||||
|
{formatZAR(act.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-32 shrink-0 text-right font-mono text-xs text-slate-400">
|
||||||
|
{formatDate(act.performedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400">
|
{activities.length > 5 && (
|
||||||
{formatDate(act.performedAt)}
|
<div className="text-center pt-1">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManageActivities}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
+ View all {activities.length} work breakdown tasks →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
)}
|
||||||
{activities.length > 4 && (
|
</div>
|
||||||
<div className="text-center pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onManageActivities}
|
|
||||||
className="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
+ View all {activities.length} work activities →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Summary KPI Bar */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3 pt-2">
|
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
|
||||||
<span className="text-sm font-bold text-white mt-0.5 block">
|
|
||||||
{timelineData.totalDurationDays} Days
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-slate-400">
|
|
||||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Milestone Progress</span>
|
|
||||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
|
||||||
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
|
|
||||||
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-1.5 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
|
||||||
style={{ width: `${milestoneProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
{/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */}
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Work Breakdown Cost</span>
|
{/* Bottom Left Navigation Arrow Button */}
|
||||||
<span className="text-sm font-bold text-cyan-400 mt-0.5 block font-mono">
|
<div className="absolute bottom-3.5 left-4 z-20">
|
||||||
{formatZAR(totalActivityBudget)}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
<span className="text-[10px] text-slate-400">{activities.length} total tasks</span>
|
onClick={handleScrollLeft}
|
||||||
|
aria-label="Shift timeline left"
|
||||||
|
title="Shift timeline left (360px)"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:-translate-x-0.5 transition-transform" />
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Earlier</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
{/* Bottom Right Navigation Arrow Button */}
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Governance State</span>
|
<div className="absolute bottom-3.5 right-4 z-20">
|
||||||
<div className="mt-1">
|
<button
|
||||||
{(() => {
|
type="button"
|
||||||
const b = getApprovalStatusBadge(project.status);
|
onClick={handleScrollRight}
|
||||||
return (
|
aria-label="Shift timeline right"
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${b.className}`}>
|
title="Shift timeline right (360px)"
|
||||||
{b.label}
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||||
</span>
|
>
|
||||||
);
|
<span className="text-[11px] font-bold uppercase tracking-wider">Later</span>
|
||||||
})()}
|
<ChevronRight className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:translate-x-0.5 transition-transform" />
|
||||||
</div>
|
</button>
|
||||||
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Total Value of Approved Project Budgets Widget */}
|
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Approved Budget Value</span>
|
|
||||||
<span className="text-sm font-bold text-emerald-400 mt-0.5 block font-mono">
|
|
||||||
{formatZAR(approvedBudgetTotal)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-slate-400">
|
|
||||||
{approvedBudgetsCount > 0
|
|
||||||
? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}`
|
|
||||||
: 'Allocated Project Budget'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
Flag,
|
||||||
|
Briefcase,
|
||||||
|
Target,
|
||||||
|
ShieldAlert,
|
||||||
|
FileCheck,
|
||||||
|
Edit2,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Coins,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles,
|
||||||
|
ExternalLink,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
|
import { useToast } from '../ui/Toast';
|
||||||
|
import { useRbac } from '../../auth/rbac';
|
||||||
|
import {
|
||||||
|
getActivities,
|
||||||
|
getMilestones,
|
||||||
|
getProjectIndicators,
|
||||||
|
getProjectRisks,
|
||||||
|
getBudgets,
|
||||||
|
updateProjectStatus,
|
||||||
|
} from '../../services/api';
|
||||||
|
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatZAR,
|
||||||
|
getApprovalStatusBadge,
|
||||||
|
getPriorityBadge,
|
||||||
|
getRiskScoreColor,
|
||||||
|
getUserEmail,
|
||||||
|
getIndustrySectorLabel,
|
||||||
|
getProjectTypeLabel,
|
||||||
|
APPROVAL_STATUS_OPTIONS,
|
||||||
|
} from '../../lib/formatters';
|
||||||
|
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
|
||||||
|
import { evaluateProjectScheduleRisk } from '../../pages/ProjectsPage';
|
||||||
|
|
||||||
|
interface ProjectViewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
project: Project | null;
|
||||||
|
programmeMap: Map<string | number, Programme>;
|
||||||
|
onOpenMilestonesModal: () => void;
|
||||||
|
onOpenActivitiesModal: () => void;
|
||||||
|
onOpenIndicatorsModal: () => void;
|
||||||
|
onOpenRisksModal: () => void;
|
||||||
|
onOpenEditProject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
project,
|
||||||
|
programmeMap,
|
||||||
|
onOpenMilestonesModal,
|
||||||
|
onOpenActivitiesModal,
|
||||||
|
onOpenIndicatorsModal,
|
||||||
|
onOpenRisksModal,
|
||||||
|
onOpenEditProject,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
|
const projectId = project?.id;
|
||||||
|
|
||||||
|
// Queries for project resources
|
||||||
|
const {
|
||||||
|
data: activities = [],
|
||||||
|
isLoading: loadingActivities,
|
||||||
|
refetch: refetchActivities,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['activities', projectId],
|
||||||
|
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||||
|
enabled: isOpen && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: milestones = [],
|
||||||
|
isLoading: loadingMilestones,
|
||||||
|
refetch: refetchMilestones,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['milestones', projectId],
|
||||||
|
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
||||||
|
enabled: isOpen && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: indicators = [],
|
||||||
|
isLoading: loadingIndicators,
|
||||||
|
refetch: refetchIndicators,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['indicators', projectId],
|
||||||
|
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
||||||
|
enabled: isOpen && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: risks = [],
|
||||||
|
isLoading: loadingRisks,
|
||||||
|
refetch: refetchRisks,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['risks', projectId],
|
||||||
|
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
||||||
|
enabled: isOpen && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allBudgets = [] } = useQuery({
|
||||||
|
queryKey: ['budgets'],
|
||||||
|
queryFn: () => getBudgets(1, 100),
|
||||||
|
enabled: isOpen && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Governance 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 updated successfully');
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toastError('Failed to update status', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate budgets
|
||||||
|
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
||||||
|
const approvedBudgets = projectBudgets.filter(
|
||||||
|
(b) =>
|
||||||
|
b.status === undefined ||
|
||||||
|
b.status === null ||
|
||||||
|
Number(b.status) === ApprovalStatus.Approved ||
|
||||||
|
Number(b.status) === 3
|
||||||
|
);
|
||||||
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||||
|
|
||||||
|
// Milestone Progress
|
||||||
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||||
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
|
|
||||||
|
// Schedule Risk
|
||||||
|
const scheduleRisk = useMemo(() => {
|
||||||
|
if (!project) return null;
|
||||||
|
return evaluateProjectScheduleRisk(project, { total: milestones.length, verified: verifiedMilestonesCount });
|
||||||
|
}, [project, milestones.length, verifiedMilestonesCount]);
|
||||||
|
|
||||||
|
// Total Duration
|
||||||
|
const durationDays = useMemo(() => {
|
||||||
|
if (!project?.startedAt || !project?.endedAt) return 0;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(
|
||||||
|
(new Date(project.endedAt).getTime() - new Date(project.startedAt).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [project?.startedAt, project?.endedAt]);
|
||||||
|
|
||||||
|
const prog = project ? programmeMap.get(project.programmeId) : null;
|
||||||
|
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
const canApproveThis = canApproveRecord('projects_milestones', project.createdBy);
|
||||||
|
const isSelf = isMaker(project.createdBy);
|
||||||
|
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
|
||||||
|
|
||||||
|
const handleRefreshAll = () => {
|
||||||
|
refetchActivities();
|
||||||
|
refetchMilestones();
|
||||||
|
refetchIndicators();
|
||||||
|
refetchRisks();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title=""
|
||||||
|
size="full-screen"
|
||||||
|
zIndex="z-[9990]"
|
||||||
|
>
|
||||||
|
<div className="space-y-6 -mt-3 text-slate-200">
|
||||||
|
{/* Top Header Banner: Project Title, ID, Sector, Status, and Primary Actions */}
|
||||||
|
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-5 sm:p-6 rounded-2xl border border-slate-800 shadow-xl relative overflow-hidden">
|
||||||
|
<div className="absolute -right-8 -bottom-8 w-48 h-48 bg-blue-600/10 rounded-full blur-2xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 relative z-10">
|
||||||
|
{/* Project Meta Info */}
|
||||||
|
<div className="space-y-2 min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<span className="text-xs font-mono font-bold text-blue-400 bg-blue-500/15 px-2.5 py-0.5 rounded-lg border border-blue-500/30">
|
||||||
|
Project #{project.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-slate-300 bg-slate-900/90 px-2.5 py-0.5 rounded-lg border border-slate-700/80">
|
||||||
|
{getIndustrySectorLabel(project.sector)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{getProjectTypeLabel(project.projectType)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className="text-xs text-slate-300">
|
||||||
|
Programme:{' '}
|
||||||
|
<strong className="text-blue-300">
|
||||||
|
{prog?.name || `Prog #${project.programmeId}`}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight leading-snug">
|
||||||
|
{project.name || `Project #${project.id}`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 flex-wrap pt-0.5">
|
||||||
|
<span>
|
||||||
|
Owner:{' '}
|
||||||
|
<strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<div className="flex items-center gap-1 font-mono text-slate-300">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{approvedBudgetTotal > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className="text-emerald-400 font-mono font-bold">
|
||||||
|
Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & Action Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 shrink-0">
|
||||||
|
{/* Schedule Risk Pill */}
|
||||||
|
{scheduleRisk && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border ${scheduleRisk.badgeClass}`}
|
||||||
|
title={scheduleRisk.reason}
|
||||||
|
>
|
||||||
|
{scheduleRisk.isOverdue ? (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||||
|
) : scheduleRisk.isAtRisk ? (
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{scheduleRisk.isOverdue
|
||||||
|
? 'Overdue Schedule'
|
||||||
|
: scheduleRisk.isAtRisk
|
||||||
|
? `At Risk (${scheduleRisk.daysRemaining}d)`
|
||||||
|
: 'On Track'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Schedule Risk Assessment"
|
||||||
|
content={scheduleRisk.reason}
|
||||||
|
size="sm"
|
||||||
|
placement="bottom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Real-time Governance Status Dropdown */}
|
||||||
|
<div className="w-36">
|
||||||
|
<select
|
||||||
|
value={project.status}
|
||||||
|
disabled={!canModifyStatus}
|
||||||
|
onChange={(e) =>
|
||||||
|
statusMutation.mutate({
|
||||||
|
projectId: project.id,
|
||||||
|
status: Number(e.target.value) as ApprovalStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
|
||||||
|
title="Project Governance Clearance Status"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs 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"
|
||||||
|
title="Review Evidence Queue"
|
||||||
|
>
|
||||||
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span className="hidden sm:inline">Evidence Queue</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenEditProject}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shadow-xs"
|
||||||
|
title="Edit Project Scope & Schedule"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span className="hidden sm:inline">Edit Scope</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
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 shadow-xs"
|
||||||
|
title="Refresh project datasets"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Narrative */}
|
||||||
|
{project.description && (
|
||||||
|
<div className="mt-3.5 pt-3 border-t border-slate-800/80 text-xs text-slate-300 leading-relaxed max-w-5xl">
|
||||||
|
<span className="font-semibold text-slate-400 mr-1.5">Scope Narrative:</span>
|
||||||
|
{project.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5 Vital Project KPI Metrics Bar */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3.5">
|
||||||
|
{/* Project Duration */}
|
||||||
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider">Project Duration</span>
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold text-white mt-1 block font-mono">
|
||||||
|
{durationDays} Days
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||||
|
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Progress */}
|
||||||
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider">Milestone Progress</span>
|
||||||
|
<Flag className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
|
<span className="text-base font-bold text-emerald-400">{milestoneProgress}%</span>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
({verifiedMilestonesCount}/{milestones.length} Verified)
|
||||||
|
</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: `${milestoneProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Work Breakdown Cost */}
|
||||||
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider">Work Breakdown Cost</span>
|
||||||
|
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold text-cyan-400 mt-1 block font-mono">
|
||||||
|
{formatZAR(totalActivityBudget)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||||
|
{activities.length} total recorded tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Governance State */}
|
||||||
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider">Governance State</span>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{(() => {
|
||||||
|
const b = getApprovalStatusBadge(project.status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${b.className}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 mt-1 block">Statutory Clearance Gate</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approved Budget Total */}
|
||||||
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between text-slate-400">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider">Approved Budget Value</span>
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold text-emerald-400 mt-1 block font-mono">
|
||||||
|
{formatZAR(approvedBudgetTotal)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
||||||
|
{approvedBudgets.length > 0
|
||||||
|
? `${approvedBudgets.length} approved budget line${approvedBudgets.length === 1 ? '' : 's'}`
|
||||||
|
: 'Allocated Project Budget'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Navigable Timeline Gantt Chart Component (Requirements 4, 5, 6) */}
|
||||||
|
<ProjectTimelineGantt
|
||||||
|
project={project}
|
||||||
|
milestones={milestones}
|
||||||
|
activities={activities}
|
||||||
|
approvedBudgetTotal={approvedBudgetTotal}
|
||||||
|
approvedBudgetsCount={approvedBudgets.length}
|
||||||
|
onManageMilestones={onOpenMilestonesModal}
|
||||||
|
onManageActivities={onOpenActivitiesModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4 Vital Resource Summary Cards (Milestones, Activities, Indicators, Risks) */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4">
|
||||||
|
{/* Card 1: Milestones */}
|
||||||
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
|
<Flag className="w-4 h-4 text-blue-400" />
|
||||||
|
<span>Contractual Milestones ({milestones.length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-emerald-400 font-bold bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
|
||||||
|
{verifiedMilestonesCount}/{milestones.length} Verified
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 py-4 text-center">No contractual milestones defined.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{milestones.slice(0, 4).map((m) => {
|
||||||
|
const sBadge = getApprovalStatusBadge(m.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-semibold text-slate-200 truncate block">{m.name}</span>
|
||||||
|
<span className={`shrink-0 text-[9px] px-1.5 py-0.2 rounded border ${sBadge.className}`}>
|
||||||
|
{sBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 flex items-center justify-between font-mono">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-slate-500" />
|
||||||
|
Due: {formatDate(m.dueAt)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">#{m.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{milestones.length > 4 && (
|
||||||
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||||
|
+{milestones.length - 4} more deliverables
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenMilestonesModal}
|
||||||
|
className="w-full py-2 px-3 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<span>Manage Milestones & Deliverables</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Activities */}
|
||||||
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
|
<Briefcase className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Work Activities ({activities.length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-cyan-400 font-bold bg-cyan-500/10 px-2 py-0.5 rounded-full border border-cyan-500/30">
|
||||||
|
{formatZAR(totalActivityBudget)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 py-4 text-center">No work activities recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activities.slice(0, 4).map((act) => (
|
||||||
|
<div
|
||||||
|
key={act.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
||||||
|
<span className="font-mono text-cyan-400 font-bold shrink-0">{formatZAR(act.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||||
|
<span>{act.category || 'Operations'}</span>
|
||||||
|
<span>{formatDate(act.performedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activities.length > 4 && (
|
||||||
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||||
|
+{activities.length - 4} more tasks
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenActivitiesModal}
|
||||||
|
className="w-full py-2 px-3 text-xs font-semibold text-cyan-400 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<span>Manage Work Activities</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Indicators */}
|
||||||
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
|
<Target className="w-4 h-4 text-indigo-400" />
|
||||||
|
<span>M&E Indicators ({indicators.length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-indigo-400 font-bold bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/30">
|
||||||
|
Results Chain
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{indicators.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 py-4 text-center">No M&E indicators defined.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{indicators.slice(0, 4).map((ind) => {
|
||||||
|
const target = Number(ind.targetAmount) || 1;
|
||||||
|
const actual = Number(ind.actualAmount) || 0;
|
||||||
|
const pct = Math.round((actual / target) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ind.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-semibold text-slate-200 truncate block">{ind.name}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
pct >= 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||||
|
<span>
|
||||||
|
Actual: <strong className="text-slate-200 font-mono">{actual}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Target: <strong className="text-slate-200 font-mono">{target}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{indicators.length > 4 && (
|
||||||
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||||
|
+{indicators.length - 4} more indicators
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenIndicatorsModal}
|
||||||
|
className="w-full py-2 px-3 text-xs font-semibold text-indigo-400 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<span>Manage M&E Indicators</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: Risks */}
|
||||||
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||||
|
<span>Risk Register ({risks.length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/30">
|
||||||
|
ISO 31000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{risks.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 py-4 text-center">No project hazards logged.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{risks.slice(0, 4).map((rk) => {
|
||||||
|
const score = (rk.likelihood || 1) * (rk.impact || 1);
|
||||||
|
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rk.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-semibold text-slate-200 truncate block">{rk.name}</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.2 rounded font-mono ${riskInfo.badge}`}>
|
||||||
|
{score}/25 {riskInfo.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-400 line-clamp-1">{rk.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{risks.length > 4 && (
|
||||||
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
||||||
|
+{risks.length - 4} more risks
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenRisksModal}
|
||||||
|
className="w-full py-2 px-3 text-xs font-semibold text-rose-400 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<span>Manage Risk Register</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ export interface ModalProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
helpText?: ReactNode;
|
helpText?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full';
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string;
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full' | string;
|
||||||
|
zIndex?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
@@ -23,6 +24,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
children,
|
children,
|
||||||
maxWidth = 'lg',
|
maxWidth = 'lg',
|
||||||
size,
|
size,
|
||||||
|
zIndex = 'z-[9999]',
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -57,13 +59,15 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
'4xl': 'max-w-4xl',
|
'4xl': 'max-w-4xl',
|
||||||
'5xl': 'max-w-5xl',
|
'5xl': 'max-w-5xl',
|
||||||
'6xl': 'max-w-6xl',
|
'6xl': 'max-w-6xl',
|
||||||
|
'7xl': 'max-w-7xl',
|
||||||
|
'full-screen': 'max-w-[96vw]',
|
||||||
full: 'max-w-full',
|
full: 'max-w-full',
|
||||||
};
|
};
|
||||||
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
|
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
|
className={`fixed inset-0 ${zIndex} flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -111,12 +112,12 @@ const ToastCard: React.FC<{
|
|||||||
<div
|
<div
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${
|
className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${
|
||||||
toast.type === 'success'
|
toast.type === 'success'
|
||||||
? 'border-emerald-500/30 text-gray-200'
|
? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20'
|
||||||
: toast.type === 'error'
|
: toast.type === 'error'
|
||||||
? 'border-rose-500/40 text-gray-200 shadow-rose-950/20'
|
? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30'
|
||||||
: 'border-blue-500/30 text-gray-200'
|
: 'border-blue-500/60 text-gray-100 shadow-blue-950/40 ring-1 ring-blue-500/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-4">
|
<div className="flex items-start gap-3 p-4">
|
||||||
@@ -360,18 +361,23 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toastRegion = (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="System Notifications"
|
||||||
|
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
|
||||||
|
style={{ zIndex: 999999 }}
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
||||||
{children}
|
{children}
|
||||||
<div
|
{typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
|
||||||
role="region"
|
|
||||||
aria-label="System Notifications"
|
|
||||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
|
|
||||||
>
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getProjects,
|
getProjects,
|
||||||
@@ -57,7 +58,7 @@ import {
|
|||||||
APPROVAL_STATUS_OPTIONS,
|
APPROVAL_STATUS_OPTIONS,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
} from '../lib/formatters';
|
} from '../lib/formatters';
|
||||||
import { ProjectExpandedRow } from '../components/projects/ProjectExpandedRow';
|
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
|
||||||
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
|
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
|
||||||
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
|
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
|
||||||
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
|
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
|
||||||
@@ -158,8 +159,9 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const [selectedSectorFilter, setSelectedSectorFilter] = useState<string | number | 'all'>('all');
|
const [selectedSectorFilter, setSelectedSectorFilter] = useState<string | number | 'all'>('all');
|
||||||
const [selectedStatusFilter, setSelectedStatusFilter] = useState<string | number | 'all'>('all');
|
const [selectedStatusFilter, setSelectedStatusFilter] = useState<string | number | 'all'>('all');
|
||||||
|
|
||||||
// Expanded Rows State (store project IDs)
|
// Active Project View Modal State
|
||||||
const [expandedRowIds, setExpandedRowIds] = useState<Set<string | number>>(new Set());
|
const [viewingProject, setViewingProject] = useState<Project | null>(null);
|
||||||
|
const [isProjectViewModalOpen, setIsProjectViewModalOpen] = useState(false);
|
||||||
|
|
||||||
// Modals Management State
|
// Modals Management State
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
@@ -369,26 +371,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
customExtractors
|
customExtractors
|
||||||
);
|
);
|
||||||
|
|
||||||
// Toggle Single Row Expansion
|
// Handler to open comprehensive Project View Modal
|
||||||
const toggleRow = (id: string | number) => {
|
const handleOpenProjectView = (p: Project, e?: React.MouseEvent) => {
|
||||||
setExpandedRowIds((prev) => {
|
if (e) e.stopPropagation();
|
||||||
const next = new Set(prev);
|
setViewingProject(p);
|
||||||
if (next.has(id)) {
|
setIsProjectViewModalOpen(true);
|
||||||
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
|
// Handlers to open resource modals
|
||||||
@@ -447,7 +434,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
}, [projects, milestonesByProject]);
|
}, [projects, milestonesByProject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto pb-12">
|
<div className="space-y-6 w-full max-w-7xl 2xl:max-w-[1600px] mx-auto pb-12">
|
||||||
{/* Header Banner */}
|
{/* 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="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="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
@@ -703,43 +690,30 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||||
Funded Projects Directory ({sortedProjects.length})
|
Funded Projects Directory ({sortedProjects.length})
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-slate-500 hidden sm:inline">
|
<span className="text-xs text-slate-400 hidden sm:inline">
|
||||||
• Click any row to expand the Gantt timeline & resource details
|
• Click "View Project" or project row to open the Project View modal & Gantt timeline
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Projects Table */}
|
{/* Projects Table */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto scrollbar-thin">
|
||||||
<table className="w-full text-left text-xs text-slate-300 border-collapse min-w-[960px]">
|
<table className="w-full text-left text-xs text-slate-300 border-collapse min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800/80 bg-[#090d16] text-[11px] font-semibold text-slate-400 select-none">
|
<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-3 min-w-[160px] max-w-[240px]">
|
||||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[180px]">
|
|
||||||
Project Name & ID
|
Project Name & ID
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[140px]">
|
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-36">
|
||||||
Parent Programme
|
Parent Programme
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[140px] whitespace-nowrap">
|
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-36 whitespace-nowrap">
|
||||||
Schedule (Timeline)
|
Schedule (Timeline)
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 w-28 whitespace-nowrap">
|
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-28 whitespace-nowrap">
|
||||||
WBS Progress
|
WBS Progress
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[125px] whitespace-nowrap">
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<span>Schedule Risk</span>
|
<span>Schedule Risk</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
@@ -750,16 +724,16 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 w-32 whitespace-nowrap">
|
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-3 w-32 whitespace-nowrap">
|
||||||
Status
|
Status
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<th className="py-3 px-4 text-right font-semibold whitespace-nowrap min-w-[170px]">Actions</th>
|
<th className="py-3 px-3 text-right font-semibold whitespace-nowrap w-44">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||||
{loadingProjects ? (
|
{loadingProjects ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="p-12 text-center text-slate-400 text-xs">
|
<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="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" />
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
<span>Loading project directory...</span>
|
<span>Loading project directory...</span>
|
||||||
@@ -768,7 +742,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : sortedProjects.length === 0 ? (
|
) : sortedProjects.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="p-12 text-center text-slate-400">
|
<td colSpan={7} className="p-12 text-center text-slate-400">
|
||||||
<div className="flex flex-col items-center justify-center space-y-3">
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
<FolderKanban className="w-10 h-10 text-slate-600" />
|
<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-sm font-semibold text-slate-300">No matching projects found</p>
|
||||||
@@ -789,7 +763,6 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedProjects.map((p) => {
|
sortedProjects.map((p) => {
|
||||||
const isExpanded = expandedRowIds.has(p.id);
|
|
||||||
const prog = programmeMap.get(p.programmeId);
|
const prog = programmeMap.get(p.programmeId);
|
||||||
const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 };
|
const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 };
|
||||||
const progressPct =
|
const progressPct =
|
||||||
@@ -799,58 +772,50 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats);
|
const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={p.id}>
|
<tr
|
||||||
{/* Main Project Row */}
|
key={p.id}
|
||||||
<tr
|
onClick={() => handleOpenProjectView(p)}
|
||||||
onClick={() => toggleRow(p.id)}
|
className="transition-colors cursor-pointer hover:bg-slate-900/60 text-slate-300 group"
|
||||||
className={`transition-colors cursor-pointer group ${
|
>
|
||||||
isExpanded
|
{/* Project Name & Meta */}
|
||||||
? 'bg-[#0f172a]/90 text-white'
|
<td className="py-3.5 px-3 max-w-[240px]">
|
||||||
: 'hover:bg-slate-900/60 text-slate-300'
|
<div className="space-y-1">
|
||||||
}`}
|
<div className="flex items-center gap-2">
|
||||||
>
|
<span
|
||||||
{/* Expand/Collapse Chevron */}
|
className="font-bold text-sm text-white group-hover:text-blue-300 transition line-clamp-1"
|
||||||
<td className="py-4 px-3 text-center">
|
title={p.name || `Project #${p.id}`}
|
||||||
<button
|
>
|
||||||
type="button"
|
{p.name || `Project #${p.id}`}
|
||||||
aria-label="Expand project row"
|
</span>
|
||||||
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>
|
</div>
|
||||||
</td>
|
<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">•</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 */}
|
{/* Parent Programme */}
|
||||||
<td className="py-4 px-4">
|
<td className="py-3.5 px-3 w-36">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-xs font-semibold text-slate-200 block truncate max-w-[160px]">
|
<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}`}
|
{prog?.name || `Prog #${p.programmeId}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
@@ -860,8 +825,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Schedule (Dates) */}
|
{/* Schedule (Dates) */}
|
||||||
<td className="py-4 px-4">
|
<td className="py-3.5 px-3 w-36">
|
||||||
<div className="space-y-0.5 font-mono text-[11px] text-slate-400">
|
<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">
|
<div className="flex items-center gap-1.5 text-slate-300">
|
||||||
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
<span>{formatDate(p.startedAt)} → {formatDate(p.endedAt)}</span>
|
<span>{formatDate(p.startedAt)} → {formatDate(p.endedAt)}</span>
|
||||||
@@ -882,8 +847,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Milestone Progress */}
|
{/* Milestone Progress */}
|
||||||
<td className="py-4 px-4">
|
<td className="py-3.5 px-3 w-28">
|
||||||
<div className="space-y-1 w-32">
|
<div className="space-y-1 w-24">
|
||||||
<div className="flex justify-between text-[11px] font-semibold">
|
<div className="flex justify-between text-[11px] font-semibold">
|
||||||
<span className={progressPct >= 100 ? 'text-emerald-400' : 'text-blue-400'}>
|
<span className={progressPct >= 100 ? 'text-emerald-400' : 'text-blue-400'}>
|
||||||
{progressPct}%
|
{progressPct}%
|
||||||
@@ -906,7 +871,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Schedule Risk Column with Icon & Tooltip */}
|
{/* Schedule Risk Column with Icon & Tooltip */}
|
||||||
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
|
<td className="py-3.5 px-3 w-32" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{scheduleRisk.isOverdue ? (
|
{scheduleRisk.isOverdue ? (
|
||||||
<span
|
<span
|
||||||
@@ -952,8 +917,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Governance Status */}
|
{/* Governance Status */}
|
||||||
<td className="py-4 px-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
<td className="py-3.5 px-3 whitespace-nowrap w-32" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="w-32">
|
<div className="w-28">
|
||||||
{(() => {
|
{(() => {
|
||||||
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
|
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
|
||||||
const isSelf = isMaker(p.createdBy);
|
const isSelf = isMaker(p.createdBy);
|
||||||
@@ -969,7 +934,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
status: Number(e.target.value) as ApprovalStatus,
|
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.5 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"
|
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) => {
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
@@ -992,51 +957,62 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Row Level Buttons: Milestones, Activities, Indicators, Risks, Edit */}
|
{/* Row Level Buttons: View Project, Milestones, Activities, Indicators, Risks, Edit */}
|
||||||
<td className="py-4 px-4 text-right whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
<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">
|
<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 */}
|
{/* Milestones Modal Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenMilestones(p, e)}
|
onClick={(e) => handleOpenMilestones(p, e)}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 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"
|
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"
|
title="Manage Contractual Milestones"
|
||||||
>
|
>
|
||||||
<Flag className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
<Flag className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
<span className="hidden xl:inline">Milestones</span>
|
<span className="hidden 2xl:inline">Milestones</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Activities Modal Button */}
|
{/* Activities Modal Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenActivities(p, e)}
|
onClick={(e) => handleOpenActivities(p, e)}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 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"
|
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"
|
title="Manage Work Breakdown Activities"
|
||||||
>
|
>
|
||||||
<Briefcase className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
<Briefcase className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
<span className="hidden xl:inline">Activities</span>
|
<span className="hidden 2xl:inline">Activities</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Indicators Modal Button */}
|
{/* Indicators Modal Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenIndicators(p, e)}
|
onClick={(e) => handleOpenIndicators(p, e)}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 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"
|
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"
|
title="Manage M&E Performance Indicators"
|
||||||
>
|
>
|
||||||
<Target className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
<Target className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||||
<span className="hidden xl:inline">Indicators</span>
|
<span className="hidden 2xl:inline">Indicators</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Risks Modal Button */}
|
{/* Risks Modal Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenRisks(p, e)}
|
onClick={(e) => handleOpenRisks(p, e)}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 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"
|
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)"
|
title="Manage Risk Register (ISO 31000)"
|
||||||
>
|
>
|
||||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
<ShieldAlert className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||||
<span className="hidden xl:inline">Risks</span>
|
<span className="hidden 2xl:inline">Risks</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Edit Project Button */}
|
{/* Edit Project Button */}
|
||||||
@@ -1051,24 +1027,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
@@ -1076,6 +1035,47 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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) */}
|
{/* Project Form Modal (Create or Edit) */}
|
||||||
<ProjectFormModal
|
<ProjectFormModal
|
||||||
isOpen={isProjectModalOpen}
|
isOpen={isProjectModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user