Files
pfm/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx
T
2026-09-26 10:28:43 +02:00

1336 lines
60 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 } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
FolderKanban,
Plus,
CheckCircle2,
Calendar,
Layers,
ChevronRight,
Clock,
Briefcase,
AlertCircle,
Flag,
FileCheck,
TrendingUp,
X,
Edit2,
} from 'lucide-react';
import {
getProjects,
createProject,
updateProject,
updateProjectStatus,
getActivities,
createActivity,
updateActivity,
getMilestones,
createMilestone,
updateMilestone,
getAllProgrammes,
getProgrammeProjects,
getActivity,
getMilestone,
getProjectIndicators,
getProjectRisks,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import {
Project,
Activity,
Milestone,
IndustrySector,
ProjectType,
ApprovalStatus,
Priority,
CreateProjectRequest,
CreateActivityRequest,
CreateMilestoneRequest,
UpdateActivityRequest,
UpdateMilestoneRequest,
} from '../types';
import {
getIndustrySectorLabel,
getProjectTypeLabel,
getApprovalStatusBadge,
getPriorityBadge,
formatDate,
formatZAR,
getUserEmail,
INDUSTRY_SECTOR_OPTIONS,
PROJECT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
const projectSchema = z.object({
name: z.string().min(4, 'Project name must be at least 4 characters'),
description: z.string().min(10, 'Provide a meaningful implementation description'),
programmeId: z.coerce.number().min(1, 'Select a programme'),
sector: z.coerce.number().min(1),
projectType: z.coerce.number().min(1),
startedAt: z.string().optional().nullable(),
endedAt: z.string().optional().nullable(),
});
const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
milestoneId: z.string().optional().nullable(),
performedAt: z.string().optional().nullable(),
});
const milestoneSchema = z.object({
name: z.string().min(4, 'Milestone deliverable required'),
dueAt: z.string().min(4, 'Due date is mandatory'),
priority: z.coerce.number().min(1).max(4),
status: z.coerce.number().optional(),
});
type ProjectFormValues = z.infer<typeof projectSchema>;
type ActivityFormValues = z.infer<typeof activitySchema>;
type MilestoneFormValues = z.infer<typeof milestoneSchema>;
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
// Modals & Editing State
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
const [editingActivity, setEditingActivity] = useState<Activity | null>(null);
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
// Queries
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects', selectedProgrammeFilter],
queryFn: () =>
selectedProgrammeFilter !== 'all'
? getProgrammeProjects(selectedProgrammeFilter)
: getProjects(),
});
// Details Queries (for currently selected project)
const activeProjectId = selectedProject ? selectedProject.id : null;
const { data: activities = [] } = useQuery({
queryKey: ['activities', activeProjectId],
queryFn: () => (activeProjectId ? getActivities(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones', activeProjectId],
queryFn: () => (activeProjectId ? getMilestones(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: indicators = [] } = useQuery({
queryKey: ['indicators', activeProjectId],
queryFn: () => (activeProjectId ? getProjectIndicators(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: risks = [] } = useQuery({
queryKey: ['risks', activeProjectId],
queryFn: () => (activeProjectId ? getProjectRisks(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
// Forms
const projectForm = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema) as any,
defaultValues: {
name: '',
description: '',
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
startedAt: '',
endedAt: '',
},
});
const activityForm = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema) as any,
defaultValues: {
title: '',
description: '',
category: '',
amount: 0,
milestoneId: '',
performedAt: '',
},
});
const milestoneForm = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema) as any,
defaultValues: {
name: '',
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
priority: Priority.High,
status: ApprovalStatus.Pending,
},
});
// Open Handlers
const openCreateProject = () => {
setEditingProject(null);
projectForm.reset({
name: '',
description: '',
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
startedAt: '',
endedAt: '',
});
setIsProjectModalOpen(true);
};
const openEditProject = (p: Project) => {
setEditingProject(p);
projectForm.reset({
name: p.name || '',
description: p.description || '',
programmeId: p.programmeId ? Number(p.programmeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
sector: p.sector || IndustrySector.AgriTech,
projectType: p.projectType || ProjectType.Innovation,
startedAt: p.startedAt ? p.startedAt.slice(0, 10) : '',
endedAt: p.endedAt ? p.endedAt.slice(0, 10) : '',
});
setIsProjectModalOpen(true);
};
const openCreateActivity = () => {
setEditingActivity(null);
activityForm.reset({
title: '',
description: '',
category: '',
amount: 0,
milestoneId: '',
performedAt: new Date().toISOString().slice(0, 10),
});
setIsActivityModalOpen(true);
};
const openEditActivity = (a: Activity) => {
setEditingActivity(a);
activityForm.reset({
title: a.title || '',
description: a.description || '',
category: a.category || '',
amount: a.amount || 0,
milestoneId: a.milestoneId ? String(a.milestoneId) : '',
performedAt: a.performedAt ? a.performedAt.slice(0, 10) : '',
});
setIsActivityModalOpen(true);
};
const openCreateMilestone = () => {
setEditingMilestone(null);
milestoneForm.reset({
name: '',
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
priority: Priority.High,
status: ApprovalStatus.Pending,
});
setIsMilestoneModalOpen(true);
};
const openEditMilestone = (m: Milestone) => {
setEditingMilestone(m);
milestoneForm.reset({
name: m.name || '',
dueAt: m.dueAt ? m.dueAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
priority: m.priority || Priority.High,
status: m.status || ApprovalStatus.Pending,
});
setIsMilestoneModalOpen(true);
};
// Mutations
const projectMutation = useMutation({
mutationFn: (data: ProjectFormValues) => {
if (editingProject) {
return updateProject({
projectId: editingProject.id,
name: data.name,
description: data.description,
startedAt: data.startedAt ? new Date(data.startedAt).toISOString() : null,
endedAt: data.endedAt ? new Date(data.endedAt).toISOString() : null,
sector: Number(data.sector),
projectType: Number(data.projectType),
});
}
return createProject(data as CreateProjectRequest);
},
onSuccess: (savedProj) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success(
editingProject ? 'Project updated' : 'Project registered',
`Project "${savedProj.name}" saved successfully`
);
setIsProjectModalOpen(false);
setEditingProject(null);
projectForm.reset();
setSelectedProject(savedProj);
},
onError: (err: unknown) => {
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
},
});
const statusMutation = useMutation({
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
updateProjectStatus({ projectId, status }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
if (selectedProject) {
setSelectedProject({ ...selectedProject, status: variables.status });
}
success('Status updated', 'Project governance status changed successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', err);
},
});
const activityMutation = useMutation({
mutationFn: (data: ActivityFormValues) => {
const parsedMilestoneId =
data.milestoneId && String(data.milestoneId).trim() !== ''
? Number(data.milestoneId)
: null;
const performedAtIso = data.performedAt ? new Date(data.performedAt).toISOString() : null;
if (editingActivity) {
return updateActivity({
activityId: editingActivity.id,
projectId: activeProjectId!,
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
}
return createActivity({
projectId: activeProjectId!,
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
success(
editingActivity ? 'Activity updated' : 'Activity added',
editingActivity
? 'Activity updated in project work breakdown'
: 'Activity registered to project work breakdown'
);
setIsActivityModalOpen(false);
setEditingActivity(null);
activityForm.reset();
},
onError: (err: unknown) => {
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
},
});
const milestoneMutation = useMutation({
mutationFn: async (data: MilestoneFormValues): Promise<void> => {
if (editingMilestone) {
await updateMilestone({
milestoneId: editingMilestone.id,
name: data.name,
dueAt: data.dueAt,
priority: Number(data.priority),
status: Number(data.status ?? editingMilestone.status),
});
} else {
await createMilestone({
projectId: activeProjectId!,
name: data.name,
dueAt: data.dueAt,
priority: Number(data.priority),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
success(
editingMilestone ? 'Milestone updated' : 'Milestone created',
editingMilestone
? 'Contractual deliverable updated successfully'
: 'Contractual deliverable added to implementation schedule'
);
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
milestoneForm.reset();
},
onError: (err: unknown) => {
toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
},
});
// Calculate project progress percentage based on completed milestones
const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0;
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
Project & Milestone Management
</h2>
<HelpTooltip
title="Work Breakdown Structure (WBS)"
content="Manage operational activities, verified contractual milestone deliverables, risk registers, and M&E performance indicator targets."
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">
Work Breakdown
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
</p>
</div>
<button
onClick={openCreateProject}
className="inline-flex items-center gap-1.5 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-md shadow-blue-500/20 transition cursor-pointer self-start sm:self-auto"
>
<Plus className="w-4 h-4" />
Create Project
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Project Directory List */}
<div className="lg:col-span-5 space-y-3">
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-1.5">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Projects Directory ({projects.length})
</h3>
<HelpTooltip
title="Project Registry"
content="Select any funded project to inspect its work breakdown structure, contractual deliverables, progress score, and M&E indicators."
size="sm"
placement="bottom"
/>
</div>
<div className="flex items-center gap-1">
<select
value={selectedProgrammeFilter}
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[190px] [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Programmes</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
</div>
{loadingProjects ? (
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
Loading projects...
</div>
) : (
<div className="space-y-2.5">
{projects.map((p) => {
const isSelected = selectedProject?.id === p.id;
const statusBadge = getApprovalStatusBadge(p.status);
return (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${
isSelected
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-bold text-sm text-white leading-snug">{p.name}</span>
<span className={`shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label.split('/')[0]}
</span>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">{p.description}</p>
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-slate-800/80 text-[11px] text-slate-400">
<span className="bg-slate-900/80 text-slate-300 border border-slate-700/80 px-2 py-0.5 rounded-md font-medium">
{getIndustrySectorLabel(p.sector)}
</span>
<span>&bull;</span>
<span className="text-slate-300">{getProjectTypeLabel(p.projectType)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Column: Detailed Project Workspace */}
<div className="lg:col-span-7">
{selectedProject ? (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-xl backdrop-blur-md overflow-hidden sticky top-6">
{/* Project Header */}
<div className="p-6 border-b border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between gap-3">
<div>
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
Project #{selectedProject.id}
</span>
<h3 className="text-lg font-bold text-white mt-1.5 leading-snug">{selectedProject.name}</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{selectedProject.description}</p>
<div className="flex flex-wrap items-center gap-3 mt-2.5 text-[11px] text-slate-400 font-medium">
<span>
Created by: <strong className="text-slate-200 font-normal">{getUserEmail(selectedProject.createdBy)}</strong>
</span>
<span>&bull;</span>
<span>
Sector: <strong className="text-slate-200 font-normal">{getIndustrySectorLabel(selectedProject.sector)}</strong>
</span>
</div>
</div>
{/* Actions & Status Change Selector */}
<div className="shrink-0 text-right space-y-2">
<div className="flex items-center justify-end gap-2">
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/15 hover:bg-blue-500/25 border border-blue-500/30 rounded-xl transition cursor-pointer"
title="Review submitted evidence for this project"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Review Evidence
</Link>
<button
type="button"
onClick={() => openEditProject(selectedProject)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
<HelpTooltip
title="Governance Approval Status"
content="Current statutory clearance stage of the project: Draft, In Review, Approved, or Active."
size="sm"
placement="bottom"
/>
</div>
<select
value={selectedProject.status}
onChange={(e) =>
statusMutation.mutate({
projectId: selectedProject.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Progress bar calculated from approved milestones */}
<div className="mt-5 pt-4 border-t border-slate-800/80">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="font-semibold text-slate-300 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Progress (Milestone-based)
<HelpTooltip
title="Calculated Progress Formula"
content="Computed dynamically as (Verified & Approved Milestones / Total Milestones) × 100%."
size="sm"
placement="top"
/>
</span>
<span className="font-bold text-blue-400">{milestoneProgress}%</span>
</div>
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 rounded-full"
style={{ width: `${milestoneProgress}%` }}
></div>
</div>
<div className="flex justify-between text-[10px] text-slate-500 mt-1">
<span>{completedMilestones} of {milestones.length} milestones verified</span>
<span>Target: 100% completion</span>
</div>
</div>
</div>
{/* Sub-tabs */}
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
<button
onClick={() => setDetailTab('activities')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'activities'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Briefcase className="w-3.5 h-3.5" />
Work Activities ({activities.length})
</button>
<button
onClick={() => setDetailTab('milestones')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'milestones'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Flag className="w-3.5 h-3.5" />
Milestones ({milestones.length})
</button>
<button
onClick={() => setDetailTab('indicators')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'indicators'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<FileCheck className="w-3.5 h-3.5" />
Indicators ({indicators.length})
</button>
<button
onClick={() => setDetailTab('risks')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'risks'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<AlertCircle className="w-3.5 h-3.5" />
Risks ({risks.length})
</button>
</div>
{/* Tab Content */}
<div className="p-5 max-h-[500px] overflow-y-auto">
{/* Activities Sub-tab */}
{detailTab === 'activities' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<HelpTooltip
title="Work Breakdown Structure"
content="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones."
size="sm"
placement="bottom"
/>
</div>
<button
onClick={openCreateActivity}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Activity
</button>
</div>
{activities.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No activities recorded yet.</p>
) : (
<div className="space-y-2.5">
{activities.map((a) => {
const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null;
return (
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<span className="font-semibold text-xs text-white block">{a.title}</span>
{a.milestoneId ? (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
<Flag className="w-2.5 h-2.5" />
Milestone #{a.milestoneId}
{linkedMilestone ? ` - ${linkedMilestone.name}` : ''}
</span>
) : (
<span className="text-[10px] text-slate-500 italic">Independent Activity</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this activity"
>
<FileCheck className="w-3 h-3 text-cyan-400" />
Evidence
</Link>
<button
onClick={() => openEditActivity(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit activity"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1.5 leading-relaxed">{a.description}</p>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
<span>Date: {formatDate(a.performedAt)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Milestones Sub-tab */}
{detailTab === 'milestones' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<HelpTooltip
title="Contractual Milestones"
content="Statutory deliverables tied to legal grant agreements. Verified completion triggers tranche disbursement releases."
size="sm"
placement="bottom"
/>
</div>
<button
onClick={openCreateMilestone}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Milestone
</button>
</div>
{milestones.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No milestones scheduled.</p>
) : (
<div className="space-y-2.5">
{milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
return (
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold text-xs text-white block">{m.name}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this milestone"
>
<FileCheck className="w-3 h-3 text-blue-400" />
Evidence
</Link>
<button
onClick={() => openEditMilestone(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit contractual milestone"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
<span className="flex items-center gap-1.5 text-slate-400">
<Clock className="w-3.5 h-3.5 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Indicators Sub-tab */}
{detailTab === 'indicators' && (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
<HelpTooltip
title="M&E Indicator Tracking"
content="Monitors quantitative performance targets (e.g. jobs created, patents filed, rural SMMEs trained) against baseline values."
size="sm"
placement="bottom"
/>
</div>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No indicators attached.</p>
) : (
indicators.map((ind) => {
const pct = ind.targetAmount > 0 ? Math.round((ind.actualAmount / ind.targetAmount) * 100) : 0;
return (
<div key={ind.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1.5">
<div className="flex justify-between text-xs font-semibold">
<span className="text-white">{ind.name}</span>
<span className={pct >= 90 ? 'text-emerald-400' : 'text-amber-400'}>{pct}%</span>
</div>
<div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: {ind.baselineAmount}</span>
<span>Target: {ind.targetAmount}</span>
<span className="font-bold text-slate-200">Actual: {ind.actualAmount}</span>
</div>
</div>
);
})
)}
</div>
)}
{/* Risks Sub-tab */}
{detailTab === 'risks' && (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
<HelpTooltip
title="ISO 31000 Risk Register"
content="Assesses specific operational risks by Likelihood (1-5) and Consequence/Impact (1-5), specifying mitigation commitments."
size="sm"
placement="bottom"
/>
</div>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No project risks logged.</p>
) : (
risks.map((rk) => (
<div key={rk.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1">
<div className="flex justify-between text-xs font-semibold text-white">
<span>{rk.name}</span>
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-mono">
Score: {rk.likelihood * rk.impact} / 25
</span>
</div>
<p className="text-xs text-slate-400">{rk.description}</p>
{rk.mitigationAction && (
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2 rounded-lg mt-1.5 border border-emerald-500/20">
<strong className="text-emerald-300">Mitigation:</strong> {rk.mitigationAction}
</p>
)}
</div>
))
)}
</div>
)}
</div>
</div>
) : (
<div className="bg-[#0f1523]/80 rounded-2xl border border-dashed border-slate-800 p-12 text-center text-slate-400 flex flex-col items-center justify-center">
<FolderKanban className="w-10 h-10 text-slate-600 mb-3" />
<p className="text-sm font-semibold text-slate-200">Select a project to inspect implementation</p>
<p className="text-xs text-slate-500 mt-1">View activities, milestones, indicators, and risk controls.</p>
</div>
)}
</div>
</div>
{/* Modal: Create or Edit Project */}
<Modal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setEditingProject(null);
}}
title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'}
subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'}
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
>
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
<HelpTooltip
title="Project Title"
content="Clear, unique operational project title corresponding to the legal agreement."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...projectForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Drone Precision Seed & Thermal Scouting"
/>
{projectForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{projectForm.formState.errors.name.message}</p>
)}
</div>
{!editingProject && (
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Parent Programme *</label>
<HelpTooltip
title="Parent Programme"
content="The national statutory funding vehicle financing this initiative."
size="sm"
placement="right"
/>
</div>
<select
{...projectForm.register('programmeId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Industry Sector *</label>
<HelpTooltip
title="Industrial Sector"
content="Primary industrial or economic sector for national cluster analytics."
size="sm"
placement="top"
/>
</div>
<select
{...projectForm.register('sector')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{INDUSTRY_SECTOR_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">
<label className="block text-xs font-semibold text-slate-300">Project Type *</label>
<HelpTooltip
title="Project Type"
content="Category of execution: Grant, Commercialisation, Infrastructure, or Research."
size="sm"
placement="top"
/>
</div>
<select
{...projectForm.register('projectType')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{PROJECT_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Start Date</label>
<HelpTooltip
title="Project Start Date"
content="Scheduled or actual project commencement date."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...projectForm.register('startedAt')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Target Completion Date</label>
<HelpTooltip
title="Target Completion Date"
content="Projected or contractual conclusion date for statutory reporting."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...projectForm.register('endedAt')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Scope & Objectives *</label>
<textarea
rows={3}
{...projectForm.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="Outline the operational work, methodology, and key milestones..."
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsProjectModalOpen(false);
setEditingProject(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={projectMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{projectMutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add or Edit Activity */}
<Modal
isOpen={isActivityModalOpen}
onClose={() => {
setIsActivityModalOpen(false);
setEditingActivity(null);
}}
title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'}
subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`}
helpText="Define operational tasks and assign them to contractual milestones. The allocated budget will be tracked against expenditure categories."
>
<form
onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Activity Title *</label>
<HelpTooltip
title="Activity Title"
content="Concise description of the specific work task to be undertaken by the project team."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...activityForm.register('title')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Cleanroom Sensor Calibration"
/>
</div>
{/* Associated Milestone Dropdown */}
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Associated Milestone (Deliverable Link)
</label>
<HelpTooltip
title="Milestone Linkage"
content="Optionally bind this activity as a work package that contributes to a contractual milestone deliverable."
size="sm"
placement="right"
/>
</div>
<select
{...activityForm.register('milestoneId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- No Milestone / Standalone Activity --</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
Milestone #{m.id}: {m.name} (Due: {formatDate(m.dueAt)})
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Budget Allocation (ZAR) *</label>
<HelpTooltip
title="Activity Budget"
content="Estimated expenditure or cost required to complete this activity."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...activityForm.register('amount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Cost Category *</label>
<HelpTooltip
title="Cost Category"
content="Budget line category (e.g. Capital Equipment, Personnel, Field Trials, Operational)."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...activityForm.register('category')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="Hardware / Field Testing"
/>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Execution / Performed Date</label>
<HelpTooltip
title="Activity Execution Date"
content="Scheduled or actual date on which the activity was performed."
size="sm"
placement="right"
/>
</div>
<input
type="date"
{...activityForm.register('performedAt')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
<textarea
rows={2}
{...activityForm.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsActivityModalOpen(false);
setEditingActivity(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={activityMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{activityMutation.isPending ? 'Saving...' : editingActivity ? 'Update Activity' : 'Add Activity'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add or Edit Milestone */}
<Modal
isOpen={isMilestoneModalOpen}
onClose={() => {
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
}}
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
>
<form
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Milestone Deliverable Name *</label>
<HelpTooltip
title="Deliverable Title"
content="Title of the agreed milestone (e.g. Inception Report, Working Prototype, Pilot Demonstration)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...milestoneForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Milestone 4: Prototype Pilot Validation Report"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Contractual Due Date *</label>
<HelpTooltip
title="Due Date"
content="Contractual cutoff deadline by which evidence must be submitted for review."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...milestoneForm.register('dueAt')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Priority Level *</label>
<HelpTooltip
title="Milestone Priority"
content="Criticality of the milestone: Low, Medium, High, or Critical path."
size="sm"
placement="top"
/>
</div>
<select
{...milestoneForm.register('priority')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{editingMilestone && (
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Approval / Verification Status</label>
<HelpTooltip
title="Verification Status"
content="Only Approved/Verified milestones contribute to the calculated progress percentage."
size="sm"
placement="right"
/>
</div>
<select
{...milestoneForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsMilestoneModalOpen(false);
setEditingMilestone(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={milestoneMutation.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-md shadow-blue-500/20 cursor-pointer"
>
{milestoneMutation.isPending ? 'Saving...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
</button>
</div>
</form>
</Modal>
</div>
);
};