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, 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 { 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), }); 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(), }); 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; 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'); const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState('all'); // Modals & Editing State const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); const [isActivityModalOpen, setIsActivityModalOpen] = useState(false); const [editingActivity, setEditingActivity] = useState(null); const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false); const [editingMilestone, setEditingMilestone] = useState(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({ 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, milestoneId: '', }, }); 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, 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, }); 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, }); setIsProjectModalOpen(true); }; const openCreateActivity = () => { setEditingActivity(null); activityForm.reset({ title: '', description: '', category: '', amount: 0, milestoneId: '', }); 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) : '', }); 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, 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; if (editingActivity) { return updateActivity({ activityId: editingActivity.id, projectId: activeProjectId!, title: data.title, description: data.description, category: data.category, amount: Number(data.amount), milestoneId: parsedMilestoneId, }); } return createActivity({ projectId: activeProjectId!, title: data.title, description: data.description, category: data.category, amount: Number(data.amount), milestoneId: parsedMilestoneId, }); }, 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 => { 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 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}

Created by: {getUserEmail(selectedProject.createdBy)} • Sector: {getIndustrySectorLabel(selectedProject.sector)}
{/* Actions & 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) => { const linkedMilestone = a.milestoneId ? milestones.find((m) => String(m.id) === String(a.milestoneId)) : null; return (
{a.title} {a.milestoneId ? ( Milestone #{a.milestoneId} {linkedMilestone ? ` - ${linkedMilestone.name}` : ''} ) : ( Independent Activity )}
{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} Milestone #{m.id}
{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 or Edit Project */} { setIsProjectModalOpen(false); setEditingProject(null); }} title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'} subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'} >
projectMutation.mutate(data as any))} className="space-y-4">
{projectForm.formState.errors.name && (

{projectForm.formState.errors.name.message}

)}
{!editingProject && (
)}