Files
pfm/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx
T
2026-10-22 11:25:55 +02:00

1938 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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: &quot;{searchTerm}&quot;
</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">
&bull; 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">&bull;</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)} &rarr; {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 &amp; 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 (&lt;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">&lt;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">&lt;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 &amp; 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>
);
};