1938 lines
91 KiB
TypeScript
1938 lines
91 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { Link, useSearchParams } from 'react-router-dom';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import {
|
||
FolderKanban,
|
||
Plus,
|
||
Search,
|
||
RotateCcw,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Flag,
|
||
Briefcase,
|
||
Target,
|
||
ShieldAlert,
|
||
Edit2,
|
||
Calendar,
|
||
Clock,
|
||
TrendingUp,
|
||
FileCheck,
|
||
AlertTriangle,
|
||
AlertCircle,
|
||
CheckCircle2,
|
||
SlidersHorizontal,
|
||
Eye,
|
||
RefreshCw,
|
||
Filter,
|
||
FileText,
|
||
Activity,
|
||
} from 'lucide-react';
|
||
import {
|
||
getProjects,
|
||
getAllProgrammes,
|
||
getProgrammeProjects,
|
||
getAllMilestones,
|
||
getAllRisks,
|
||
getAllIndicators,
|
||
createIndicator,
|
||
recordIndicatorActual,
|
||
getBudgets,
|
||
} from '../services/api';
|
||
import { Modal } from '../components/ui/Modal';
|
||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||
import { ExportButton } from '../components/ui/ExportButton';
|
||
import { ExportColumn } from '../utils/exportUtils';
|
||
import { useToast } from '../components/ui/Toast';
|
||
import { useRbac } from '../auth/rbac';
|
||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||
import {
|
||
Project,
|
||
Programme,
|
||
Milestone,
|
||
Indicator,
|
||
CreateIndicatorRequest,
|
||
UnitOfMeasure,
|
||
ApprovalStatus,
|
||
IndustrySector,
|
||
ProjectType,
|
||
} from '../types';
|
||
import {
|
||
getIndustrySectorLabel,
|
||
getProjectTypeLabel,
|
||
getApprovalStatusBadge,
|
||
getUnitOfMeasureLabel,
|
||
UNIT_OF_MEASURE_OPTIONS,
|
||
formatDate,
|
||
formatZAR,
|
||
INDUSTRY_SECTOR_OPTIONS,
|
||
PROJECT_TYPE_OPTIONS,
|
||
APPROVAL_STATUS_OPTIONS,
|
||
getUserEmail,
|
||
} from '../lib/formatters';
|
||
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
|
||
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
|
||
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
|
||
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
|
||
import { ProjectIndicatorsModal } from '../components/projects/ProjectIndicatorsModal';
|
||
import { ProjectRisksModal } from '../components/projects/ProjectRisksModal';
|
||
|
||
|
||
const indicatorSchema = z.object({
|
||
projectId: z.coerce.number().min(1, 'Select project'),
|
||
name: z.string().min(5, 'Indicator statement / definition is required'),
|
||
unitOfMeasure: z.coerce.number().min(1, 'Unit of measure is required'),
|
||
baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'),
|
||
targetAmount: z.coerce.number().min(1, 'Target benchmark must be > 0'),
|
||
});
|
||
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
|
||
|
||
export interface ProjectScheduleRisk {
|
||
isAtRisk: boolean;
|
||
isOverdue: boolean;
|
||
daysRemaining: number | null;
|
||
reason: string;
|
||
badgeClass: string;
|
||
iconType: 'critical' | 'warning' | 'on_track' | 'pending';
|
||
}
|
||
|
||
export const evaluateProjectScheduleRisk = (
|
||
project: Project,
|
||
milestoneStats?: { total: number; verified: number }
|
||
): ProjectScheduleRisk => {
|
||
const hasStarted = Boolean(project.startedAt);
|
||
const targetDateStr = project.endedAt;
|
||
|
||
if (!targetDateStr) {
|
||
return {
|
||
isAtRisk: false,
|
||
isOverdue: false,
|
||
daysRemaining: null,
|
||
reason: 'No contractual target completion date configured yet.',
|
||
badgeClass: 'bg-slate-800 text-slate-400 border-slate-700/80',
|
||
iconType: 'pending',
|
||
};
|
||
}
|
||
|
||
const now = Date.now();
|
||
const targetTime = new Date(targetDateStr).getTime();
|
||
const diffDays = Math.ceil((targetTime - now) / (1000 * 60 * 60 * 24));
|
||
|
||
const totalMilestones = milestoneStats?.total || 0;
|
||
const verifiedMilestones = milestoneStats?.verified || 0;
|
||
const isMilestonesIncomplete = totalMilestones === 0 || verifiedMilestones < totalMilestones;
|
||
|
||
if (hasStarted && isMilestonesIncomplete) {
|
||
if (diffDays < 0) {
|
||
return {
|
||
isAtRisk: true,
|
||
isOverdue: true,
|
||
daysRemaining: diffDays,
|
||
reason: `Target completion deadline passed ${Math.abs(diffDays)} day${
|
||
Math.abs(diffDays) === 1 ? '' : 's'
|
||
} ago with ${totalMilestones - verifiedMilestones} unverified milestone deliverable(s) remaining.`,
|
||
badgeClass: 'bg-rose-500/15 text-rose-400 border-rose-500/40 font-bold animate-pulse',
|
||
iconType: 'critical',
|
||
};
|
||
}
|
||
if (diffDays <= 30) {
|
||
return {
|
||
isAtRisk: true,
|
||
isOverdue: false,
|
||
daysRemaining: diffDays,
|
||
reason: `Project is ${diffDays} day${
|
||
diffDays === 1 ? '' : 's'
|
||
} away from target due date with only ${verifiedMilestones} of ${totalMilestones} milestones verified.`,
|
||
badgeClass: 'bg-amber-500/15 text-amber-300 border-amber-500/40 font-semibold',
|
||
iconType: 'warning',
|
||
};
|
||
}
|
||
}
|
||
|
||
if (totalMilestones > 0 && verifiedMilestones === totalMilestones) {
|
||
return {
|
||
isAtRisk: false,
|
||
isOverdue: false,
|
||
daysRemaining: diffDays,
|
||
reason: `All ${totalMilestones} contractual milestone deliverables verified and approved.`,
|
||
badgeClass: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||
iconType: 'on_track',
|
||
};
|
||
}
|
||
|
||
return {
|
||
isAtRisk: false,
|
||
isOverdue: false,
|
||
daysRemaining: diffDays,
|
||
reason: `${diffDays} days remaining until target completion deadline.`,
|
||
badgeClass: 'bg-slate-800/80 text-slate-300 border-slate-700/80',
|
||
iconType: 'on_track',
|
||
};
|
||
};
|
||
|
||
export const ProjectsPage: React.FC = () => {
|
||
const queryClient = useQueryClient();
|
||
const { success, error: toastError } = useToast();
|
||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||
|
||
// Filter & Search States
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
|
||
const [selectedSectorFilter, setSelectedSectorFilter] = useState<string | number | 'all'>('all');
|
||
const [selectedStatusFilter, setSelectedStatusFilter] = useState<string | number | 'all'>('all');
|
||
|
||
// Active Project View Modal State
|
||
const [viewingProject, setViewingProject] = useState<Project | null>(null);
|
||
const [isProjectViewModalOpen, setIsProjectViewModalOpen] = useState(false);
|
||
|
||
// Modals Management State
|
||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||
|
||
const [activeModalProject, setActiveModalProject] = useState<Project | null>(null);
|
||
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
|
||
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
|
||
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
|
||
const [isRisksModalOpen, setIsRisksModalOpen] = useState(false);
|
||
|
||
// Queries
|
||
const { data: programmes = [] } = useQuery({
|
||
queryKey: ['programmes'],
|
||
queryFn: () => getAllProgrammes(),
|
||
});
|
||
|
||
const {
|
||
data: projects = [],
|
||
isLoading: loadingProjects,
|
||
refetch: refetchProjects,
|
||
isFetching: isFetchingProjects,
|
||
} = useQuery({
|
||
queryKey: ['projects', selectedProgrammeFilter],
|
||
queryFn: () =>
|
||
selectedProgrammeFilter !== 'all'
|
||
? getProgrammeProjects(selectedProgrammeFilter)
|
||
: getProjects(),
|
||
});
|
||
|
||
const { data: allMilestones = [] } = useQuery({
|
||
queryKey: ['milestones-all'],
|
||
queryFn: () => getAllMilestones(),
|
||
});
|
||
|
||
const { data: allRisks = [] } = useQuery({
|
||
queryKey: ['risks-all'],
|
||
queryFn: () => getAllRisks(),
|
||
});
|
||
|
||
const { data: allIndicators = [] } = useQuery({
|
||
queryKey: ['indicators-all'],
|
||
queryFn: () => getAllIndicators(),
|
||
});
|
||
|
||
const { data: allBudgets = [] } = useQuery({
|
||
queryKey: ['budgets'],
|
||
queryFn: () => getBudgets(1, 100),
|
||
});
|
||
|
||
// Programme lookup map
|
||
const programmeMap = useMemo(() => {
|
||
const map = new Map<string | number, Programme>();
|
||
programmes.forEach((p) => map.set(p.id, p));
|
||
return map;
|
||
}, [programmes]);
|
||
|
||
// Project Milestones Count Map
|
||
const milestonesByProject = useMemo(() => {
|
||
const map = new Map<string | number, { total: number; verified: number }>();
|
||
allMilestones.forEach((m) => {
|
||
const pId = m.projectId;
|
||
if (pId !== undefined && pId !== null) {
|
||
const current = map.get(pId) || { total: 0, verified: 0 };
|
||
current.total += 1;
|
||
if (m.status === ApprovalStatus.Approved) {
|
||
current.verified += 1;
|
||
}
|
||
map.set(pId, current);
|
||
}
|
||
});
|
||
return map;
|
||
}, [allMilestones]);
|
||
|
||
// Project Approved Budget Map
|
||
const approvedBudgetsByProject = useMemo(() => {
|
||
const map = new Map<string | number, number>();
|
||
allBudgets.forEach((b) => {
|
||
const pId = b.projectId;
|
||
if (pId !== undefined && pId !== null) {
|
||
if (
|
||
b.status === undefined ||
|
||
b.status === null ||
|
||
Number(b.status) === ApprovalStatus.Approved ||
|
||
Number(b.status) === 3
|
||
) {
|
||
const current = map.get(pId) || 0;
|
||
map.set(pId, current + (Number(b.amount) || 0));
|
||
}
|
||
}
|
||
});
|
||
return map;
|
||
}, [allBudgets]);
|
||
|
||
// Filtered Projects
|
||
const filteredProjects = useMemo(() => {
|
||
return projects.filter((p) => {
|
||
// Search term
|
||
if (searchTerm.trim()) {
|
||
const query = searchTerm.toLowerCase();
|
||
const matchesName = (p.name || '').toLowerCase().includes(query);
|
||
const matchesDesc = (p.description || '').toLowerCase().includes(query);
|
||
const matchesId = String(p.id).toLowerCase().includes(query);
|
||
const matchesSector = getIndustrySectorLabel(p.sector).toLowerCase().includes(query);
|
||
if (!matchesName && !matchesDesc && !matchesId && !matchesSector) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Sector filter
|
||
if (selectedSectorFilter !== 'all' && Number(p.sector) !== Number(selectedSectorFilter)) {
|
||
return false;
|
||
}
|
||
|
||
// Status filter
|
||
if (selectedStatusFilter !== 'all' && Number(p.status) !== Number(selectedStatusFilter)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}, [projects, searchTerm, selectedSectorFilter, selectedStatusFilter]);
|
||
|
||
// Export Columns Configuration
|
||
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
|
||
{ header: 'Project ID', key: 'id', format: (val) => `#${val}` },
|
||
{ header: 'Project Name', key: 'name', format: (val) => val || 'Untitled' },
|
||
{
|
||
header: 'Parent Programme',
|
||
key: 'programmeId',
|
||
format: (val) => programmeMap.get(val)?.name || `Prog #${val}`,
|
||
},
|
||
{ header: 'Industry Sector', key: 'sector', format: (val) => getIndustrySectorLabel(val) },
|
||
{ header: 'Project Type', key: 'projectType', format: (val) => getProjectTypeLabel(val) },
|
||
{ header: 'Start Date', key: 'startedAt', format: (val) => formatDate(val) },
|
||
{ header: 'Target End Date', key: 'endedAt', format: (val) => formatDate(val) },
|
||
{
|
||
header: 'Schedule Risk Alert',
|
||
key: 'id',
|
||
format: (_, p) => {
|
||
const stats = milestonesByProject.get(p.id);
|
||
const risk = evaluateProjectScheduleRisk(p, stats);
|
||
return risk.isAtRisk ? (risk.isOverdue ? `OVERDUE: ${risk.reason}` : `AT RISK: ${risk.reason}`) : 'ON TRACK';
|
||
},
|
||
},
|
||
{
|
||
header: 'Milestone Progress',
|
||
key: 'id',
|
||
format: (_, p) => {
|
||
const stats = milestonesByProject.get(p.id);
|
||
if (!stats || stats.total === 0) return '0 / 0 (0%)';
|
||
const pct = Math.round((stats.verified / stats.total) * 100);
|
||
return `${stats.verified} / ${stats.total} (${pct}%)`;
|
||
},
|
||
},
|
||
{
|
||
header: 'Approved Budget (ZAR)',
|
||
key: 'id',
|
||
format: (_, p) => {
|
||
const amt = approvedBudgetsByProject.get(p.id) || 0;
|
||
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||
},
|
||
},
|
||
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
||
|
||
// Table Sorting
|
||
const customExtractors = {
|
||
name: (p: Project) => (p.name || '').toLowerCase(),
|
||
programme: (p: Project) => (programmeMap.get(p.programmeId)?.name || '').toLowerCase(),
|
||
status: (p: Project) => Number(p.status) || 0,
|
||
progress: (p: Project) => {
|
||
const stats = milestonesByProject.get(p.id);
|
||
return stats && stats.total > 0 ? (stats.verified / stats.total) * 100 : 0;
|
||
},
|
||
duration: (p: Project) => {
|
||
if (!p.startedAt || !p.endedAt) return 0;
|
||
return new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime();
|
||
},
|
||
risk: (p: Project) => {
|
||
const stats = milestonesByProject.get(p.id);
|
||
const risk = evaluateProjectScheduleRisk(p, stats);
|
||
if (risk.isOverdue) return 1;
|
||
if (risk.isAtRisk) return 2;
|
||
return 3;
|
||
},
|
||
};
|
||
|
||
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
|
||
filteredProjects,
|
||
'risk',
|
||
'asc',
|
||
customExtractors
|
||
);
|
||
|
||
// Handler to open comprehensive Project View Modal
|
||
const handleOpenProjectView = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setViewingProject(p);
|
||
setIsProjectViewModalOpen(true);
|
||
};
|
||
|
||
// Handlers to open resource modals
|
||
const handleOpenMilestones = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setActiveModalProject(p);
|
||
setIsMilestonesModalOpen(true);
|
||
};
|
||
|
||
const handleOpenActivities = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setActiveModalProject(p);
|
||
setIsActivitiesModalOpen(true);
|
||
};
|
||
|
||
const handleOpenIndicators = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setActiveModalProject(p);
|
||
setIsIndicatorsModalOpen(true);
|
||
};
|
||
|
||
const handleOpenRisks = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setActiveModalProject(p);
|
||
setIsRisksModalOpen(true);
|
||
};
|
||
|
||
const handleOpenEditProject = (p: Project, e?: React.MouseEvent) => {
|
||
if (e) e.stopPropagation();
|
||
setEditingProject(p);
|
||
setIsProjectModalOpen(true);
|
||
};
|
||
|
||
const handleOpenCreateProject = () => {
|
||
setEditingProject(null);
|
||
setIsProjectModalOpen(true);
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setSearchTerm('');
|
||
setSelectedProgrammeFilter('all');
|
||
setSelectedSectorFilter('all');
|
||
setSelectedStatusFilter('all');
|
||
};
|
||
|
||
// Key KPI Aggregations
|
||
const totalApprovedProjects = projects.filter((p) => p.status === ApprovalStatus.Approved).length;
|
||
const verifiedMilestonesTotal = allMilestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||
const milestoneCompletionRate =
|
||
allMilestones.length > 0 ? Math.round((verifiedMilestonesTotal / allMilestones.length) * 100) : 0;
|
||
const atRiskProjectsCount = useMemo(() => {
|
||
return projects.filter((p) => {
|
||
const stats = milestonesByProject.get(p.id);
|
||
return evaluateProjectScheduleRisk(p, stats).isAtRisk;
|
||
}).length;
|
||
}, [projects, milestonesByProject]);
|
||
|
||
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const tabParam = searchParams.get('tab');
|
||
const [activeTab, setActiveTab] = useState<'projects' | 'indicators' | 'actuals'>(
|
||
tabParam === 'indicators' || tabParam === 'actuals' ? tabParam : 'projects'
|
||
);
|
||
|
||
useEffect(() => {
|
||
const t = searchParams.get('tab');
|
||
if (t === 'projects' || t === 'indicators' || t === 'actuals') {
|
||
setActiveTab(t);
|
||
}
|
||
}, [searchParams]);
|
||
|
||
const handleTabChange = (tab: 'projects' | 'indicators' | 'actuals') => {
|
||
setActiveTab(tab);
|
||
setSearchParams((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.set('tab', tab);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Indicators & Actuals Modals & States
|
||
const [isCreateIndicatorModalOpen, setIsCreateIndicatorModalOpen] = useState(false);
|
||
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
||
const [actualInput, setActualInput] = useState<number>(0);
|
||
|
||
// Indicators Tab Filter States
|
||
const [indicatorSearchTerm, setIndicatorSearchTerm] = useState('');
|
||
const [indicatorProjectFilter, setIndicatorProjectFilter] = useState<string | number | 'all'>('all');
|
||
const [indicatorUnitFilter, setIndicatorUnitFilter] = useState<string | number | 'all'>('all');
|
||
|
||
// Actuals Tab Filter States
|
||
const [actualSearchTerm, setActualSearchTerm] = useState('');
|
||
const [actualProjectFilter, setActualProjectFilter] = useState<string | number | 'all'>('all');
|
||
const [actualStatusFilter, setActualStatusFilter] = useState<string>('all');
|
||
|
||
// Indicator Status Thresholds (BR-008 & BR-009: >=90% On Track, 70-89% At Risk, <70% Off Track)
|
||
const getIndicatorStatus = (actual: number, target: number) => {
|
||
if (!target) return { label: 'Pending Target', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
|
||
const pct = (actual / target) * 100;
|
||
if (pct >= 90) return { label: '≥90% (On Track)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
|
||
if (pct >= 70) return { label: '70–89% (At Risk)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
||
return { label: '<70% (Underperforming)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||
};
|
||
|
||
const indicatorForm = useForm<IndicatorFormValues>({
|
||
resolver: zodResolver(indicatorSchema),
|
||
defaultValues: {
|
||
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
|
||
name: '',
|
||
unitOfMeasure: UnitOfMeasure.Count,
|
||
baselineAmount: 0,
|
||
targetAmount: 100,
|
||
},
|
||
});
|
||
|
||
const indicatorMutation = useMutation({
|
||
mutationFn: (data: CreateIndicatorRequest) => createIndicator(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||
success('Indicator Defined', 'Performance indicator added to project monitoring plan (M08)');
|
||
setIsCreateIndicatorModalOpen(false);
|
||
indicatorForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Failed to create indicator', err);
|
||
},
|
||
});
|
||
|
||
const actualMutation = useMutation({
|
||
mutationFn: ({ id, amount }: { id: string | number; amount: number }) =>
|
||
recordIndicatorActual(id, amount),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['indicators'] });
|
||
success('Actual Value Recorded', 'M&E variance and indicator status recalculated (BR-009)');
|
||
setSelectedIndicatorForActual(null);
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Failed to record actual', err);
|
||
},
|
||
});
|
||
|
||
// Filtered Indicators
|
||
const filteredIndicatorsList = useMemo(() => {
|
||
return allIndicators.filter((ind) => {
|
||
if (indicatorProjectFilter !== 'all' && String(ind.projectId) !== String(indicatorProjectFilter)) {
|
||
return false;
|
||
}
|
||
if (indicatorUnitFilter !== 'all' && String(ind.unitOfMeasure) !== String(indicatorUnitFilter)) {
|
||
return false;
|
||
}
|
||
if (!indicatorSearchTerm.trim()) return true;
|
||
const q = indicatorSearchTerm.toLowerCase();
|
||
const name = (ind.name || '').toLowerCase();
|
||
const projId = String(ind.projectId || '').toLowerCase();
|
||
const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase();
|
||
return name.includes(q) || projId.includes(q) || unit.includes(q);
|
||
});
|
||
}, [allIndicators, indicatorProjectFilter, indicatorUnitFilter, indicatorSearchTerm]);
|
||
|
||
// Filtered Actuals
|
||
const filteredActualsList = useMemo(() => {
|
||
return allIndicators.filter((ind) => {
|
||
if (actualProjectFilter !== 'all' && String(ind.projectId) !== String(actualProjectFilter)) {
|
||
return false;
|
||
}
|
||
if (actualStatusFilter !== 'all') {
|
||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||
if (actualStatusFilter === 'green' && !statusObj.label.startsWith('≥90%')) return false;
|
||
if (actualStatusFilter === 'amber' && !statusObj.label.startsWith('70–89%')) return false;
|
||
if (actualStatusFilter === 'red' && !statusObj.label.startsWith('<70%')) return false;
|
||
if (actualStatusFilter === 'pending' && statusObj.label !== 'Pending Target') return false;
|
||
}
|
||
if (!actualSearchTerm.trim()) return true;
|
||
const q = actualSearchTerm.toLowerCase();
|
||
const name = (ind.name || '').toLowerCase();
|
||
const projId = String(ind.projectId || '').toLowerCase();
|
||
const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase();
|
||
return name.includes(q) || projId.includes(q) || unit.includes(q);
|
||
});
|
||
}, [allIndicators, actualProjectFilter, actualStatusFilter, actualSearchTerm]);
|
||
|
||
// Exports
|
||
const indicatorExportColumns: ExportColumn<Indicator>[] = [
|
||
{ header: 'Indicator ID', key: 'id' },
|
||
{ header: 'Indicator Statement', key: 'name' },
|
||
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
|
||
{ header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) },
|
||
{ header: 'Baseline Value', key: 'baselineAmount' },
|
||
{ header: 'Target Value', key: 'targetAmount' },
|
||
];
|
||
|
||
const actualExportColumns: ExportColumn<Indicator>[] = [
|
||
{ header: 'Indicator Statement', key: 'name' },
|
||
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
|
||
{ header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) },
|
||
{ header: 'Baseline', key: 'baselineAmount' },
|
||
{ header: 'Target Benchmark', key: 'targetAmount' },
|
||
{ header: 'Actual Recorded', key: 'actualAmount' },
|
||
{
|
||
header: 'Variance',
|
||
key: 'actualAmount',
|
||
format: (val, item) => `${Number(item.targetAmount) - Number(val)}`,
|
||
},
|
||
{
|
||
header: 'Achievement %',
|
||
key: 'actualAmount',
|
||
format: (val, item) => (item.targetAmount ? `${Math.round((Number(val) / Number(item.targetAmount)) * 100)}%` : '—'),
|
||
},
|
||
{
|
||
header: 'Status',
|
||
key: 'actualAmount',
|
||
format: (val, item) => getIndicatorStatus(Number(val), Number(item.targetAmount)).label,
|
||
},
|
||
];
|
||
|
||
// Table Sort Hooks
|
||
const {
|
||
sortedData: sortedIndicatorsData,
|
||
sortColumn: indSortCol,
|
||
sortDirection: indSortDir,
|
||
handleSort: handleIndSort,
|
||
} = useTableSort<Indicator>(filteredIndicatorsList, null, null, {
|
||
name: (ind) => ind.name,
|
||
projectId: (ind) => ind.projectId,
|
||
unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure),
|
||
baselineAmount: (ind) => ind.baselineAmount,
|
||
targetAmount: (ind) => ind.targetAmount,
|
||
});
|
||
|
||
const {
|
||
sortedData: sortedActualsData,
|
||
sortColumn: actSortCol,
|
||
sortDirection: actSortDir,
|
||
handleSort: handleActSort,
|
||
} = useTableSort<Indicator>(filteredActualsList, null, null, {
|
||
name: (ind) => ind.name,
|
||
projectId: (ind) => ind.projectId,
|
||
unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure),
|
||
baselineAmount: (ind) => ind.baselineAmount,
|
||
targetAmount: (ind) => ind.targetAmount,
|
||
actualAmount: (ind) => ind.actualAmount,
|
||
status: (ind) => getIndicatorStatus(ind.actualAmount, ind.targetAmount).label,
|
||
});
|
||
return (
|
||
<div className="space-y-6 w-full max-w-7xl 2xl:max-w-[1600px] mx-auto pb-12">
|
||
{/* Header Banner */}
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||
<div className="space-y-2 relative z-10">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||
<FolderKanban className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2.5 flex-wrap">
|
||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||
Project & Milestone Management
|
||
</h1>
|
||
<HelpTooltip
|
||
title="Work Breakdown Structure (WBS)"
|
||
content="Interactive table of funded projects. Expand any row to inspect timeline Gantt charts, deliverables, activities, M&E indicators, and risk registers."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||
Interactive WBS Console
|
||
</span>
|
||
</div>
|
||
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||
Manage operational activities, verified contractual milestone deliverables, risk matrices, and M&E performance indicator targets.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
||
{activeTab === 'projects' && canAdd('projects_milestones') && (
|
||
<button
|
||
onClick={handleOpenCreateProject}
|
||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>Create Project</span>
|
||
</button>
|
||
)}
|
||
{activeTab === 'indicators' && canAdd('projects_milestones') && (
|
||
<button
|
||
onClick={() => setIsCreateIndicatorModalOpen(true)}
|
||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>Define Indicator</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 3 Navigation Tabs: Projects, Indicators, Actuals */}
|
||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTabChange('projects')}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||
activeTab === 'projects'
|
||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||
}`}
|
||
>
|
||
<FolderKanban className="w-4 h-4" />
|
||
<span>Projects Directory ({projects.length})</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTabChange('indicators')}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||
activeTab === 'indicators'
|
||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||
}`}
|
||
>
|
||
<TrendingUp className="w-4 h-4" />
|
||
<span>Performance Indicators ({allIndicators.length})</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTabChange('actuals')}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||
activeTab === 'actuals'
|
||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||
}`}
|
||
>
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>Realized Actuals & Variance ({allIndicators.length})</span>
|
||
</button>
|
||
</div>
|
||
|
||
{activeTab === 'projects' && (
|
||
<div className="space-y-6">
|
||
{/* KPI Overview Summary Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Projects</span>
|
||
<FolderKanban className="w-4 h-4 text-blue-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-white">{projects.length}</span>
|
||
<span className="text-xs text-slate-400">({totalApprovedProjects} Approved)</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Under {programmes.length} parent programmes</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Milestone Progress</span>
|
||
<Flag className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-emerald-400">{milestoneCompletionRate}%</span>
|
||
<span className="text-xs text-slate-400">({verifiedMilestonesTotal}/{allMilestones.length})</span>
|
||
</div>
|
||
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-2 overflow-hidden border border-slate-800">
|
||
<div
|
||
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||
style={{ width: `${milestoneCompletionRate}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Schedule Risk Alerts</span>
|
||
<AlertTriangle className={`w-4 h-4 ${atRiskProjectsCount > 0 ? 'text-amber-400 animate-pulse' : 'text-slate-500'}`} />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className={`text-2xl font-bold ${atRiskProjectsCount > 0 ? 'text-amber-400' : 'text-slate-300'}`}>
|
||
{atRiskProjectsCount}
|
||
</span>
|
||
<span className="text-xs text-slate-400">Imminent / Overdue</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Due with unverified deliverables</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">M&E Indicators</span>
|
||
<Target className="w-4 h-4 text-indigo-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-indigo-400">{allIndicators.length}</span>
|
||
<span className="text-xs text-slate-400">Tracked KPIs</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Results & impact metrics</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter and Search Controls Bar */}
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm space-y-3">
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||
{/* Search Input */}
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
placeholder="Search projects by name, ID, sector, or keywords..."
|
||
className="w-full pl-9 pr-3 py-2 bg-slate-900/90 border border-slate-700/80 rounded-xl text-xs text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40"
|
||
/>
|
||
</div>
|
||
|
||
{/* Quick Filters */}
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
{/* Programme Filter */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Programme:</span>
|
||
<select
|
||
value={selectedProgrammeFilter}
|
||
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
|
||
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[180px] cursor-pointer"
|
||
>
|
||
<option value="all">All Programmes</option>
|
||
{programmes.map((pr) => (
|
||
<option key={pr.id} value={pr.id}>
|
||
{pr.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Sector Filter */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Sector:</span>
|
||
<select
|
||
value={selectedSectorFilter}
|
||
onChange={(e) => setSelectedSectorFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[160px] cursor-pointer"
|
||
>
|
||
<option value="all">All Sectors</option>
|
||
{INDUSTRY_SECTOR_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-slate-400 font-medium hidden sm:inline">Status:</span>
|
||
<select
|
||
value={selectedStatusFilter}
|
||
onChange={(e) => setSelectedStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||
className="text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/40 max-w-[140px] cursor-pointer"
|
||
>
|
||
<option value="all">All Statuses</option>
|
||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Clear Filter Button */}
|
||
{(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
|
||
<button
|
||
type="button"
|
||
onClick={resetFilters}
|
||
title="Reset all search and filter settings"
|
||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||
>
|
||
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-400" />
|
||
<span>Reset</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* Export Button */}
|
||
<ExportButton
|
||
claim="projects_milestones"
|
||
title="Projects & Milestones Register"
|
||
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
||
filename="projects-milestones-register"
|
||
columns={projectExportColumns}
|
||
data={sortedProjects}
|
||
size="sm"
|
||
/>
|
||
|
||
{/* Single Refresh Button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
refetchProjects();
|
||
queryClient.invalidateQueries({ queryKey: ['milestones-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||
queryClient.invalidateQueries({ queryKey: ['risks-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
|
||
}}
|
||
title="Refresh project and milestone datasets"
|
||
className={`p-2 rounded-xl text-blue-400 hover:text-blue-300 bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 transition cursor-pointer ${
|
||
isFetchingProjects ? 'animate-spin' : ''
|
||
}`}
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Active Filter Tags */}
|
||
{(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
|
||
<div className="flex items-center gap-2 pt-2 border-t border-slate-800/60 text-xs text-slate-400 flex-wrap">
|
||
<span className="text-[11px] font-semibold text-slate-500">Active Filters:</span>
|
||
{searchTerm && (
|
||
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
|
||
Search: "{searchTerm}"
|
||
</span>
|
||
)}
|
||
{selectedProgrammeFilter !== 'all' && (
|
||
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
|
||
Prog: {programmeMap.get(selectedProgrammeFilter)?.name || `#${selectedProgrammeFilter}`}
|
||
</span>
|
||
)}
|
||
{selectedSectorFilter !== 'all' && (
|
||
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
|
||
Sector: {getIndustrySectorLabel(Number(selectedSectorFilter))}
|
||
</span>
|
||
)}
|
||
{selectedStatusFilter !== 'all' && (
|
||
<span className="bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-md text-[11px]">
|
||
Status: {getApprovalStatusBadge(Number(selectedStatusFilter)).label}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={resetFilters}
|
||
className="text-[11px] text-blue-400 hover:underline cursor-pointer ml-1"
|
||
>
|
||
Clear all
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main Projects Table Container */}
|
||
<div className="bg-[#0f1523]/80 rounded-3xl border border-slate-800/80 shadow-2xl backdrop-blur-md overflow-hidden">
|
||
{/* Table Header Bar */}
|
||
<div className="p-4 sm:px-6 border-b border-slate-800/80 flex items-center justify-between flex-wrap gap-3 bg-[#0d121f]/90">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
||
Funded Projects Directory ({sortedProjects.length})
|
||
</h3>
|
||
<span className="text-xs text-slate-400 hidden sm:inline">
|
||
• Click "View Project" or project row to open the Project View modal & Gantt timeline
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Projects Table */}
|
||
<div className="overflow-x-auto scrollbar-thin">
|
||
<table className="w-full text-left text-xs text-slate-300 border-collapse min-w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-800/80 bg-[#090d16] text-[11px] font-semibold text-slate-400 select-none">
|
||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4 min-w-[200px]">
|
||
Project Name & ID
|
||
</SortableTh>
|
||
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4 w-48">
|
||
Parent Programme
|
||
</SortableTh>
|
||
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4 w-44 whitespace-nowrap">
|
||
Schedule (Timeline)
|
||
</SortableTh>
|
||
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4 w-36 whitespace-nowrap">
|
||
WBS Progress
|
||
</SortableTh>
|
||
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4 w-40 whitespace-nowrap">
|
||
<div className="flex items-center gap-1">
|
||
<span>Schedule Risk</span>
|
||
<HelpTooltip
|
||
title="Schedule Risk Assessment"
|
||
content="Identifies projects that have started and are approaching their target due date (or are overdue) while statutory deliverables/milestones remain unverified."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
</SortableTh>
|
||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||
{loadingProjects ? (
|
||
<tr>
|
||
<td colSpan={6} className="p-12 text-center text-slate-400 text-xs">
|
||
<div className="flex flex-col items-center justify-center space-y-2">
|
||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||
<span>Loading project directory...</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : sortedProjects.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="p-12 text-center text-slate-400">
|
||
<div className="flex flex-col items-center justify-center space-y-3">
|
||
<FolderKanban className="w-10 h-10 text-slate-600" />
|
||
<p className="text-sm font-semibold text-slate-300">No matching projects found</p>
|
||
<p className="text-xs text-slate-500">
|
||
{searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all'
|
||
? 'Try adjusting your search query or filters.'
|
||
: 'Register a project to begin building your work breakdown structure.'}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenCreateProject}
|
||
className="mt-2 inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" /> Create First Project
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
sortedProjects.map((p) => {
|
||
const prog = programmeMap.get(p.programmeId);
|
||
const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 };
|
||
const progressPct =
|
||
milestoneStats.total > 0
|
||
? Math.round((milestoneStats.verified / milestoneStats.total) * 100)
|
||
: 0;
|
||
const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats);
|
||
|
||
return (
|
||
<tr
|
||
key={p.id}
|
||
onClick={() => handleOpenProjectView(p)}
|
||
className="transition-colors cursor-pointer hover:bg-slate-900/60 text-slate-300 group"
|
||
>
|
||
{/* Project Name & Meta */}
|
||
<td className="py-3.5 px-4 min-w-[200px]">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className="font-bold text-sm text-white group-hover:text-blue-300 transition line-clamp-1"
|
||
title={p.name || `Project #${p.id}`}
|
||
>
|
||
{p.name || `Project #${p.id}`}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 flex-wrap text-[11px]">
|
||
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/10 px-1.5 py-0.2 rounded border border-blue-500/25 shrink-0">
|
||
#{p.id}
|
||
</span>
|
||
<span
|
||
className="bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded text-slate-300 text-[10px] truncate max-w-[140px]"
|
||
title={getIndustrySectorLabel(p.sector)}
|
||
>
|
||
{getIndustrySectorLabel(p.sector)}
|
||
</span>
|
||
<span className="text-slate-500">•</span>
|
||
<span
|
||
className="text-slate-400 text-[10px] truncate max-w-[140px]"
|
||
title={getProjectTypeLabel(p.projectType)}
|
||
>
|
||
{getProjectTypeLabel(p.projectType)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Parent Programme */}
|
||
<td className="py-3.5 px-4 w-48">
|
||
<div className="space-y-0.5">
|
||
<span
|
||
className="text-xs font-semibold text-slate-200 block truncate max-w-[170px]"
|
||
title={prog?.name || `Programme #${p.programmeId}`}
|
||
>
|
||
{prog?.name || `Prog #${p.programmeId}`}
|
||
</span>
|
||
<span className="text-[10px] text-slate-500 font-mono">
|
||
Programme #{p.programmeId}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Schedule (Dates) */}
|
||
<td className="py-3.5 px-4 w-44">
|
||
<div className="space-y-0.5 font-mono text-[11px] text-slate-400 whitespace-nowrap">
|
||
<div className="flex items-center gap-1.5 text-slate-300">
|
||
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||
<span>{formatDate(p.startedAt)} → {formatDate(p.endedAt)}</span>
|
||
</div>
|
||
{p.startedAt && p.endedAt && (
|
||
<span className="text-[10px] text-slate-500 block">
|
||
{Math.max(
|
||
1,
|
||
Math.round(
|
||
(new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime()) /
|
||
(1000 * 60 * 60 * 24 * 30)
|
||
)
|
||
)}{' '}
|
||
Months Estimated Duration
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
{/* Milestone Progress */}
|
||
<td className="py-3.5 px-4 w-36">
|
||
<div className="space-y-1 w-28">
|
||
<div className="flex justify-between text-[11px] font-semibold">
|
||
<span className={progressPct >= 100 ? 'text-emerald-400' : 'text-blue-400'}>
|
||
{progressPct}%
|
||
</span>
|
||
<span className="text-slate-500 text-[10px]">
|
||
({milestoneStats.verified}/{milestoneStats.total})
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-1.5 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||
<div
|
||
className={`h-full rounded-full transition-all duration-300 ${
|
||
progressPct >= 100
|
||
? 'bg-emerald-500'
|
||
: 'bg-gradient-to-r from-blue-500 to-indigo-500'
|
||
}`}
|
||
style={{ width: `${progressPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Schedule Risk Column with Icon & Tooltip */}
|
||
<td className="py-3.5 px-4 w-40" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center gap-1.5">
|
||
{scheduleRisk.isOverdue ? (
|
||
<span
|
||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold border ${scheduleRisk.badgeClass}`}
|
||
title={scheduleRisk.reason}
|
||
>
|
||
<AlertTriangle className="w-3 h-3 text-rose-400 shrink-0" />
|
||
<span>Overdue</span>
|
||
</span>
|
||
) : scheduleRisk.isAtRisk ? (
|
||
<span
|
||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border ${scheduleRisk.badgeClass}`}
|
||
title={scheduleRisk.reason}
|
||
>
|
||
<AlertCircle className="w-3 h-3 text-amber-400 shrink-0" />
|
||
<span>At Risk ({scheduleRisk.daysRemaining}d)</span>
|
||
</span>
|
||
) : scheduleRisk.iconType === 'on_track' ? (
|
||
<span
|
||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${scheduleRisk.badgeClass}`}
|
||
title={scheduleRisk.reason}
|
||
>
|
||
<CheckCircle2 className="w-3 h-3 text-emerald-400 shrink-0" />
|
||
<span>On Track</span>
|
||
</span>
|
||
) : (
|
||
<span
|
||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] border ${scheduleRisk.badgeClass}`}
|
||
title={scheduleRisk.reason}
|
||
>
|
||
<Clock className="w-3 h-3 text-slate-500 shrink-0" />
|
||
<span>Scheduled</span>
|
||
</span>
|
||
)}
|
||
|
||
<HelpTooltip
|
||
title="Schedule Risk Breakdown"
|
||
content={scheduleRisk.reason}
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Row Action: View Project (Opens Large WBS & Gantt Console) */}
|
||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => handleOpenProjectView(p, e)}
|
||
className="inline-flex items-center gap-1.5 px-3.5 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 active:scale-95"
|
||
title="Open Project View & Interactive Gantt Timeline"
|
||
>
|
||
<Eye className="w-3.5 h-3.5" />
|
||
<span>View Project</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 2: Performance Indicators (M08) */}
|
||
{activeTab === 'indicators' && (
|
||
<div className="space-y-5">
|
||
{/* Indicators KPI Summary Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Indicators</span>
|
||
<TrendingUp className="w-4 h-4 text-indigo-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-white">{allIndicators.length}</span>
|
||
<span className="text-xs text-slate-400">Defined metrics</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Across all active projects</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Targets Set</span>
|
||
<Target className="w-4 h-4 text-blue-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-blue-400">{allIndicators.filter((i) => Number(i.targetAmount) > 0).length}</span>
|
||
<span className="text-xs text-slate-400">Contracted milestones</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">M&E logframe benchmarks</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Baselines Recorded</span>
|
||
<FileCheck className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-emerald-400">{allIndicators.filter((i) => Number(i.baselineAmount) >= 0).length}</span>
|
||
<span className="text-xs text-slate-400">Pre-grant baselines</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Groundwork observations</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Measurement Units</span>
|
||
<SlidersHorizontal className="w-4 h-4 text-amber-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-amber-400">{new Set(allIndicators.map((i) => i.unitOfMeasure)).size}</span>
|
||
<span className="text-xs text-slate-400">Active dimensions</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Standardized measurement scales</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Indicators Toolbar */}
|
||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search indicators by statement, project ID..."
|
||
value={indicatorSearchTerm}
|
||
onChange={(e) => setIndicatorSearchTerm(e.target.value)}
|
||
className="w-full pl-9 pr-4 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
||
<select
|
||
value={indicatorProjectFilter}
|
||
onChange={(e) => setIndicatorProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||
>
|
||
<option value="all">All Projects ({projects.length})</option>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name} (#{p.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
|
||
<select
|
||
value={indicatorUnitFilter}
|
||
onChange={(e) => setIndicatorUnitFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||
>
|
||
<option value="all">All Units</option>
|
||
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIndicatorSearchTerm('');
|
||
setIndicatorProjectFilter('all');
|
||
setIndicatorUnitFilter('all');
|
||
}}
|
||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||
>
|
||
<RotateCcw className="w-3 h-3" />
|
||
<span>Reset</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 self-end lg:self-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
|
||
success('Refreshed', 'Latest performance metrics loaded from API.');
|
||
}}
|
||
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
||
<span>Refresh</span>
|
||
</button>
|
||
|
||
<ExportButton
|
||
claim="projects_milestones"
|
||
title="M&E Performance Indicators"
|
||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||
columns={indicatorExportColumns}
|
||
data={filteredIndicatorsList}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Indicators Table Card */}
|
||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||
Performance Indicators & Target Benchmarks
|
||
</h3>
|
||
<span className="text-xs text-slate-400 font-mono">
|
||
Showing {sortedIndicatorsData.length} of {allIndicators.length} indicators
|
||
</span>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-xs border-collapse">
|
||
<thead>
|
||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||
<SortableTh column="name" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-5">
|
||
Indicator Statement
|
||
</SortableTh>
|
||
<SortableTh column="projectId" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||
Project
|
||
</SortableTh>
|
||
<SortableTh column="unitOfMeasure" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} className="py-3.5 px-4">
|
||
Unit of Measure
|
||
</SortableTh>
|
||
<SortableTh column="baselineAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
||
Baseline
|
||
</SortableTh>
|
||
<SortableTh column="targetAmount" currentColumn={indSortCol} direction={indSortDir} onSort={handleIndSort} align="right" className="py-3.5 px-4 text-right">
|
||
Target
|
||
</SortableTh>
|
||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{sortedIndicatorsData.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||
? 'No indicators match your filter criteria.'
|
||
: 'No performance indicators defined yet. Click "Define Indicator" above to begin.'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
sortedIndicatorsData.map((ind) => (
|
||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||
<td className="py-3.5 px-5 text-right">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedIndicatorForActual(ind);
|
||
setActualInput(ind.actualAmount);
|
||
}}
|
||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-600 px-3 py-1.5 rounded-lg border border-blue-500/25 transition cursor-pointer"
|
||
>
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
<span>Record Actual</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 3: Realized Actuals & Variance Ledger (M08 / BR-009) */}
|
||
{activeTab === 'actuals' && (
|
||
<div className="space-y-5">
|
||
{/* Actuals RAG Summary Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">On Track (≥90%)</span>
|
||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-emerald-400">
|
||
{allIndicators.filter((i) => i.targetAmount > 0 && (i.actualAmount / i.targetAmount) >= 0.9).length}
|
||
</span>
|
||
<span className="text-xs text-slate-400">Satisfactory progress</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">≥90% of target benchmark achieved</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">At Risk (70–89%)</span>
|
||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-amber-400">
|
||
{allIndicators.filter((i) => {
|
||
if (!i.targetAmount) return false;
|
||
const r = i.actualAmount / i.targetAmount;
|
||
return r >= 0.7 && r < 0.9;
|
||
}).length}
|
||
</span>
|
||
<span className="text-xs text-slate-400">Requires monitoring</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Moderately behind milestones</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Underperforming (<70%)</span>
|
||
<AlertCircle className="w-4 h-4 text-rose-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-rose-400">
|
||
{allIndicators.filter((i) => i.targetAmount > 0 && i.actualAmount > 0 && (i.actualAmount / i.targetAmount) < 0.7).length}
|
||
</span>
|
||
<span className="text-xs text-slate-400">Corrective action</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1"><70% of target achieved</p>
|
||
</div>
|
||
|
||
<div className="bg-[#0f1523]/80 p-4 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending Actuals</span>
|
||
<Clock className="w-4 h-4 text-slate-400" />
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className="text-2xl font-bold text-slate-300">
|
||
{allIndicators.filter((i) => !i.actualAmount || Number(i.actualAmount) === 0).length}
|
||
</span>
|
||
<span className="text-xs text-slate-400">Unrecorded</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">Awaiting field monitoring returns</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actuals Toolbar */}
|
||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search realized actuals by indicator, project..."
|
||
value={actualSearchTerm}
|
||
onChange={(e) => setActualSearchTerm(e.target.value)}
|
||
className="w-full pl-9 pr-4 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
||
<select
|
||
value={actualProjectFilter}
|
||
onChange={(e) => setActualProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||
>
|
||
<option value="all">All Projects ({projects.length})</option>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name} (#{p.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||
Performance:
|
||
</label>
|
||
<select
|
||
value={actualStatusFilter}
|
||
onChange={(e) => setActualStatusFilter(e.target.value)}
|
||
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||
>
|
||
<option value="all">All RAG Statuses</option>
|
||
<option value="green">≥90% On Track</option>
|
||
<option value="amber">70–89% At Risk</option>
|
||
<option value="red"><70% Underperforming</option>
|
||
<option value="pending">Pending Actuals</option>
|
||
</select>
|
||
</div>
|
||
|
||
{(actualSearchTerm || actualProjectFilter !== 'all' || actualStatusFilter !== 'all') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActualSearchTerm('');
|
||
setActualProjectFilter('all');
|
||
setActualStatusFilter('all');
|
||
}}
|
||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||
>
|
||
<RotateCcw className="w-3 h-3" />
|
||
<span>Reset</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 self-end lg:self-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
queryClient.invalidateQueries({ queryKey: ['indicators-all'] });
|
||
success('Refreshed', 'Latest performance metrics loaded from API.');
|
||
}}
|
||
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
||
<span>Refresh</span>
|
||
</button>
|
||
|
||
<ExportButton
|
||
claim="projects_milestones"
|
||
title="M&E Realized Actuals & Variance"
|
||
subtitle="Measured indicator returns versus contractual benchmarks"
|
||
filename={`pfm-actuals-${new Date().toISOString().split('T')[0]}`}
|
||
columns={actualExportColumns}
|
||
data={filteredActualsList}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actuals Table Card */}
|
||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||
Realized Actuals & Variance Ledger
|
||
</h3>
|
||
<span className="text-xs text-slate-400 font-mono">
|
||
Showing {sortedActualsData.length} of {allIndicators.length} returns
|
||
</span>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-xs border-collapse">
|
||
<thead>
|
||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||
<SortableTh column="name" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} className="py-3.5 px-5">
|
||
Indicator Statement
|
||
</SortableTh>
|
||
<SortableTh column="projectId" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} className="py-3.5 px-4">
|
||
Project
|
||
</SortableTh>
|
||
<SortableTh column="unitOfMeasure" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} className="py-3.5 px-4">
|
||
Unit
|
||
</SortableTh>
|
||
<SortableTh column="baselineAmount" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} align="right" className="py-3.5 px-4 text-right">
|
||
Baseline
|
||
</SortableTh>
|
||
<SortableTh column="targetAmount" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} align="right" className="py-3.5 px-4 text-right">
|
||
Target
|
||
</SortableTh>
|
||
<SortableTh column="actualAmount" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} align="right" className="py-3.5 px-4 text-right">
|
||
Actual
|
||
</SortableTh>
|
||
<th className="py-3.5 px-4 text-right">Achievement %</th>
|
||
<SortableTh column="status" currentColumn={actSortCol} direction={actSortDir} onSort={handleActSort} className="py-3.5 px-4">
|
||
RAG Status
|
||
</SortableTh>
|
||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{sortedActualsData.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={9} className="py-12 text-center text-slate-500">
|
||
<CheckCircle2 className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||
{actualSearchTerm || actualProjectFilter !== 'all' || actualStatusFilter !== 'all'
|
||
? 'No indicator returns match your filter criteria.'
|
||
: 'No indicator returns recorded yet. Click "Record Actual" to log field returns.'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
sortedActualsData.map((ind) => {
|
||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||
const achievementPct = ind.targetAmount ? Math.round((Number(ind.actualAmount) / Number(ind.targetAmount)) * 100) : 0;
|
||
return (
|
||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
||
{ind.actualAmount}
|
||
</td>
|
||
<td className="py-3.5 px-4 text-right font-mono text-xs">
|
||
<span className={achievementPct >= 90 ? 'text-emerald-400 font-semibold' : achievementPct >= 70 ? 'text-amber-400 font-semibold' : 'text-rose-400 font-semibold'}>
|
||
{ind.targetAmount ? `${achievementPct}%` : '—'}
|
||
</span>
|
||
</td>
|
||
<td className="py-3.5 px-4">
|
||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
||
{statusObj.label}
|
||
</span>
|
||
</td>
|
||
<td className="py-3.5 px-5 text-right">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedIndicatorForActual(ind);
|
||
setActualInput(ind.actualAmount);
|
||
}}
|
||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||
>
|
||
<Edit2 className="w-3 h-3" />
|
||
<span>Record Actual</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</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) */}
|
||
<ProjectFormModal
|
||
isOpen={isProjectModalOpen}
|
||
onClose={() => {
|
||
setIsProjectModalOpen(false);
|
||
setEditingProject(null);
|
||
}}
|
||
editingProject={editingProject}
|
||
programmes={programmes}
|
||
defaultProgrammeId={selectedProgrammeFilter !== 'all' ? selectedProgrammeFilter : undefined}
|
||
/>
|
||
|
||
{/* Milestones Management Modal */}
|
||
<ProjectMilestonesModal
|
||
isOpen={isMilestonesModalOpen}
|
||
onClose={() => {
|
||
setIsMilestonesModalOpen(false);
|
||
setActiveModalProject(null);
|
||
}}
|
||
project={activeModalProject}
|
||
/>
|
||
|
||
{/* Activities Management Modal */}
|
||
<ProjectActivitiesModal
|
||
isOpen={isActivitiesModalOpen}
|
||
onClose={() => {
|
||
setIsActivitiesModalOpen(false);
|
||
setActiveModalProject(null);
|
||
}}
|
||
project={activeModalProject}
|
||
/>
|
||
|
||
{/* Indicators Management Modal */}
|
||
<ProjectIndicatorsModal
|
||
isOpen={isIndicatorsModalOpen}
|
||
onClose={() => {
|
||
setIsIndicatorsModalOpen(false);
|
||
setActiveModalProject(null);
|
||
}}
|
||
project={activeModalProject}
|
||
/>
|
||
|
||
{/* Risks Management Modal */}
|
||
<ProjectRisksModal
|
||
isOpen={isRisksModalOpen}
|
||
onClose={() => {
|
||
setIsRisksModalOpen(false);
|
||
setActiveModalProject(null);
|
||
}}
|
||
project={activeModalProject}
|
||
/>
|
||
|
||
{/* Modal: Define M&E Performance Indicator */}
|
||
<Modal
|
||
isOpen={isCreateIndicatorModalOpen}
|
||
onClose={() => setIsCreateIndicatorModalOpen(false)}
|
||
title="Define M&E Performance Indicator"
|
||
subtitle="Establish quantitative output, outcome, or impact metric for project logframe (M08)"
|
||
helpText="Every performance indicator strictly enforces: Baseline -> Target -> Actual -> Variance -> Evidence (BR-009)."
|
||
>
|
||
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
|
||
<HelpTooltip
|
||
title="Target Project"
|
||
content="The project whose performance is evaluated by this metric."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<select
|
||
{...indicatorForm.register('projectId')}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||
>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name} (#{p.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Indicator Statement *</label>
|
||
<HelpTooltip
|
||
title="Indicator Statement"
|
||
content="Precise measurable definition (e.g. Number of SMMEs trained in clean technology, Patents filed)."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Number of commercial prototype units deployed in field test"
|
||
{...indicatorForm.register('name')}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Unit of Measure *</label>
|
||
<HelpTooltip
|
||
title="Unit Dimension"
|
||
content="Counting unit (e.g. Number, Percentage, Currency ZAR, Rating Score)."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<select
|
||
{...indicatorForm.register('unitOfMeasure')}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
|
||
>
|
||
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Baseline Value *</label>
|
||
<HelpTooltip
|
||
title="Pre-grant Baseline"
|
||
content="Observed initial starting value prior to project intervention."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
{...indicatorForm.register('baselineAmount')}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Target Value *</label>
|
||
<HelpTooltip
|
||
title="Target Benchmark"
|
||
content="Contracted goal to achieve before project completion."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
{...indicatorForm.register('targetAmount')}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCreateIndicatorModalOpen(false)}
|
||
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={indicatorMutation.isPending}
|
||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
||
>
|
||
{indicatorMutation.isPending ? 'Saving...' : 'Define Indicator'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Record Actual Amount */}
|
||
<Modal
|
||
isOpen={!!selectedIndicatorForActual}
|
||
onClose={() => setSelectedIndicatorForActual(null)}
|
||
title="Record Indicator Actual Value"
|
||
subtitle={selectedIndicatorForActual?.name || ''}
|
||
helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating (BR-009)."
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300">
|
||
<span>Baseline: <strong className="text-white">{selectedIndicatorForActual?.baselineAmount}</strong></span>
|
||
<span>Target Benchmark: <strong className="text-white">{selectedIndicatorForActual?.targetAmount}</strong></span>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<label className="block text-xs font-semibold text-slate-300">Actual Realized Value *</label>
|
||
<HelpTooltip
|
||
title="Monitored Measurement"
|
||
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
value={actualInput}
|
||
onChange={(e) => setActualInput(Number(e.target.value))}
|
||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
|
||
/>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedIndicatorForActual(null)}
|
||
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
selectedIndicatorForActual &&
|
||
actualMutation.mutate({
|
||
id: selectedIndicatorForActual.id,
|
||
amount: actualInput,
|
||
})
|
||
}
|
||
disabled={actualMutation.isPending}
|
||
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
||
>
|
||
{actualMutation.isPending ? 'Recording...' : 'Submit Actual Value'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
</div>
|
||
);
|
||
};
|