import React, { useState } from 'react'; 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, } from 'lucide-react'; import { getProjects, createProject, updateProjectStatus, getActivities, createActivity, getMilestones, createMilestone, getAllProgrammes, getProjectIndicators, getProjectRisks, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { Project, IndustrySector, ProjectType, ApprovalStatus, Priority, CreateProjectRequest, CreateActivityRequest, CreateMilestoneRequest, } from '../types'; import { getIndustrySectorLabel, getProjectTypeLabel, getApprovalStatusBadge, getPriorityBadge, formatDate, formatZAR, 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), }); 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'), }); 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), }); type ProjectFormValues = z.infer; type ActivityFormValues = z.infer; type MilestoneFormValues = z.infer; export const ProjectsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const [selectedProject, setSelectedProject] = useState(null); const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities'); // Modals const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isActivityModalOpen, setIsActivityModalOpen] = useState(false); const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false); // Queries const { data: projects = [], isLoading: loadingProjects } = useQuery({ queryKey: ['projects'], queryFn: () => getProjects(), }); const { data: programmes = [] } = useQuery({ queryKey: ['programmes'], queryFn: () => getAllProgrammes(), }); // 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({ resolver: zodResolver(projectSchema) as any, defaultValues: { name: '', description: '', programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0, sector: IndustrySector.AgriTech, projectType: ProjectType.Innovation, }, }); const activityForm = useForm({ resolver: zodResolver(activitySchema) as any, defaultValues: { title: '', description: '', category: '', amount: 0, }, }); const milestoneForm = useForm({ resolver: zodResolver(milestoneSchema) as any, defaultValues: { name: '', dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), priority: Priority.High, }, }); // Mutations const projectMutation = useMutation({ mutationFn: (data: CreateProjectRequest) => createProject(data), onSuccess: (newProj) => { queryClient.invalidateQueries({ queryKey: ['projects'] }); success('Project registered', `Project "${newProj.name}" created successfully`); setIsProjectModalOpen(false); projectForm.reset(); setSelectedProject(newProj); }, onError: (err: unknown) => { toastError('Failed to create project', String(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', String(err)); }, }); const activityMutation = useMutation({ mutationFn: (data: CreateActivityRequest) => createActivity(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] }); success('Activity added', 'Activity registered to project work breakdown'); setIsActivityModalOpen(false); activityForm.reset(); }, onError: (err: unknown) => { toastError('Failed to add activity', String(err)); }, }); const milestoneMutation = useMutation({ mutationFn: (data: CreateMilestoneRequest) => createMilestone(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] }); success('Milestone created', 'Contractual deliverable added to implementation schedule'); setIsMilestoneModalOpen(false); milestoneForm.reset(); }, onError: (err: unknown) => { toastError('Failed to add milestone', String(err)); }, }); // Calculate project progress based on milestones (FR-M04-018) const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length; const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0; return (
{/* Page Header */}

Project & Milestone Management

Work Breakdown

Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.

{/* Left Column: Project Directory List */}

Projects Directory ({projects.length})

{loadingProjects ? (
Loading projects...
) : (
{projects.map((p) => { const isSelected = selectedProject?.id === p.id; const statusBadge = getApprovalStatusBadge(p.status); return (
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' }`} >
{p.name} {statusBadge.label.split('/')[0]}

{p.description}

{getIndustrySectorLabel(p.sector)} • {getProjectTypeLabel(p.projectType)}
); })}
)}
{/* Right Column: Detailed Project Workspace */}
{selectedProject ? (
{/* Project Header */}
Project #{selectedProject.id}

{selectedProject.name}

{selectedProject.description}

{/* Status Change Selector */}
{/* Progress bar (FR-M04-018: calculated from approved milestones) */}
Calculated Progress (Milestone-based) {milestoneProgress}%
{completedMilestones} of {milestones.length} milestones verified Target: 100% completion
{/* Sub-tabs */}
{/* Tab Content */}
{/* Activities Sub-tab */} {detailTab === 'activities' && (
Project Work Breakdown
{activities.length === 0 ? (

No activities recorded yet.

) : (
{activities.map((a) => (
{a.title} {formatZAR(a.amount)}

{a.description}

{a.category} Date: {formatDate(a.performedAt)}
))}
)}
)} {/* Milestones Sub-tab */} {detailTab === 'milestones' && (
Contractual Milestones
{milestones.length === 0 ? (

No milestones scheduled.

) : (
{milestones.map((m) => { const statusBadge = getApprovalStatusBadge(m.status); const priBadge = getPriorityBadge(m.priority); return (
{m.name} {statusBadge.label}
Due: {formatDate(m.dueAt)} {priBadge.label}
); })}
)}
)} {/* Indicators Sub-tab */} {detailTab === 'indicators' && (
Assigned Project Indicators {indicators.length === 0 ? (

No indicators attached.

) : ( indicators.map((ind) => { const pct = ind.targetAmount > 0 ? Math.round((ind.actualAmount / ind.targetAmount) * 100) : 0; return (
{ind.name} = 90 ? 'text-emerald-400' : 'text-amber-400'}>{pct}%
Baseline: {ind.baselineAmount} Target: {ind.targetAmount} Actual: {ind.actualAmount}
); }) )}
)} {/* Risks Sub-tab */} {detailTab === 'risks' && (
Project Risk Register (M09) {risks.length === 0 ? (

No project risks logged.

) : ( risks.map((rk) => (
{rk.name} Score: {rk.likelihood * rk.impact} / 25

{rk.description}

{rk.mitigationAction && (

Mitigation: {rk.mitigationAction}

)}
)) )}
)}
) : (

Select a project to inspect implementation

View activities, milestones, indicators, and risk controls.

)}
{/* Modal: Create Project */} setIsProjectModalOpen(false)} title="Create Funded Project" subtitle="Operational entity under an approved Programme" >
projectMutation.mutate(data as any))} className="space-y-4">
{projectForm.formState.errors.name && (

{projectForm.formState.errors.name.message}

)}