import React, { useState, useMemo } 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 { Link } from 'react-router-dom'; import { Flag, Plus, Edit2, Clock, FileCheck, CheckCircle2, AlertCircle, TrendingUp, X, RotateCcw, Lock, Coins, Briefcase, Target, AlertTriangle, } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { HelpTooltip } from '../ui/HelpTooltip'; import { useToast } from '../ui/Toast'; import { getMilestones, createMilestone, updateMilestone, getActivities, getProjectIndicators, getBudgets, } from '../../services/api'; import { Project, Milestone, Priority, ApprovalStatus, Activity, Indicator } from '../../types'; import { useRbac } from '../../auth/rbac'; import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge, PRIORITY_OPTIONS, APPROVAL_STATUS_OPTIONS, } from '../../lib/formatters'; const milestoneSchema = z.object({ name: z.string().min(4, 'Deliverable title must be at least 4 characters'), dueAt: z.string().min(4, 'Due date is mandatory'), priority: z.coerce.number().min(1).max(4), status: z.coerce.number().optional(), }); type MilestoneFormValues = z.infer; interface ProjectMilestonesModalProps { isOpen: boolean; onClose: () => void; project: Project | null; } export const ProjectMilestonesModal: React.FC = ({ isOpen, onClose, project, }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac(); const [isFormOpen, setIsFormOpen] = useState(false); const [editingMilestone, setEditingMilestone] = useState(null); const projectId = project?.id; const { data: milestones = [], isLoading: isLoadingMilestones, refetch, } = useQuery({ queryKey: ['milestones', projectId], queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])), enabled: isOpen && !!projectId, }); const { data: activities = [], isLoading: isLoadingActivities, } = useQuery({ queryKey: ['activities', projectId], queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])), enabled: isOpen && !!projectId, }); const { data: indicators = [], isLoading: isLoadingIndicators, } = useQuery({ queryKey: ['indicators', projectId], queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])), enabled: isOpen && !!projectId, }); const { data: allBudgets = [] } = useQuery({ queryKey: ['budgets'], queryFn: () => getBudgets(1, 100), enabled: isOpen, }); // Calculate project approved budget const approvedBudgetTotal = useMemo(() => { const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId)); return projectBudgets .filter((b) => b.status === undefined || b.status === null || Number(b.status) === 3) .reduce((sum, b) => sum + (Number(b.amount) || 0), 0); }, [allBudgets, projectId]); // Total project value is the sum total from all milestones (sum of their activities) const totalProjectMilestonesValue = useMemo(() => { return milestones.reduce((sum, m) => { const mActs = activities.filter((a) => String(a.milestoneId) === String(m.id)); return sum + mActs.reduce((s, a) => s + (Number(a.amount) || 0), 0); }, 0); }, [milestones, activities]); // Total project actuals reported by partner from indicators const totalProjectActuals = useMemo(() => { return indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0); }, [indicators]); const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length; const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0; const form = useForm({ resolver: zodResolver(milestoneSchema), defaultValues: { name: '', dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), priority: Priority.High, status: ApprovalStatus.Pending, }, }); const openCreateForm = () => { setEditingMilestone(null); form.reset({ name: '', dueAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), priority: Priority.High, status: ApprovalStatus.Pending, }); setIsFormOpen(true); }; const openEditForm = (m: Milestone) => { setEditingMilestone(m); form.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, }); setIsFormOpen(true); }; const mutation = useMutation({ mutationFn: async (data: MilestoneFormValues): Promise => { if (!projectId) return; 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: projectId, name: data.name, dueAt: data.dueAt, priority: Number(data.priority), }); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['milestones', projectId] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['milestones-all'] }); success( editingMilestone ? 'Milestone Updated' : 'Milestone Created', editingMilestone ? 'Contractual deliverable updated successfully' : 'Deliverable added to project implementation schedule' ); setIsFormOpen(false); setEditingMilestone(null); form.reset(); }, onError: (err: unknown) => { toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to create milestone', err); }, }); if (!project) return null; return ( { setIsFormOpen(false); setEditingMilestone(null); onClose(); }} title={`Milestones & Deliverables: ${project.name}`} subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`} helpText="Contractual milestones represent formal deliverables. The monetary value of each milestone is dynamically aggregated from its scheduled activities vs indicator actuals. The project's total value is the sum of all milestones." maxWidth="4xl" >
{/* KPI Cards: Project Value Roll-Up & Performance */}
Total Project Value
{formatZAR(totalProjectMilestonesValue)}
Sum of all milestones
Approved Budget
{formatZAR(approvedBudgetTotal)}
Allocated budget ceiling
Partner Actuals
{formatZAR(totalProjectActuals)}
Incurred material costs
Milestone Progress
{progressPct}%
{verifiedCount} of {milestones.length} verified
{/* Action Header */}
Deliverable Milestones: {milestones.length}
{!isFormOpen && canAdd('projects_milestones') && ( )}
{/* Milestone Create/Edit Form */} {isFormOpen && (
{editingMilestone ? `Edit Milestone #${editingMilestone.id}` : 'Define New Contractual Milestone'}
mutation.mutate(data))} className="space-y-3">
{form.formState.errors.name && (

{form.formState.errors.name.message}

)}
{form.formState.errors.dueAt && (

{form.formState.errors.dueAt.message}

)}
{editingMilestone && (
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (

Maker-Checker: Cannot verify or approve a milestone that you authored.

)}
)}
)} {/* Milestones List */}
Project Deliverables Schedule ({milestones.length})
{isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
Loading milestones...
) : milestones.length === 0 ? (

No milestones registered yet

Define contractual deliverables to monitor implementation progress.

) : (
{milestones.map((m) => { const statusBadge = getApprovalStatusBadge(m.status); const priBadge = getPriorityBadge(m.priority); // Milestone activities const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id)); const plannedMilestoneValue = mActivities.reduce( (sum, a) => sum + (Number(a.amount) || 0), 0 ); // Milestone indicators (linked to activities under this milestone) const mActivityIds = new Set(mActivities.map((a) => String(a.id))); const mIndicators = indicators.filter( (i) => i.activityId && mActivityIds.has(String(i.activityId)) ); const actualMilestoneValue = mIndicators.reduce( (sum, i) => sum + (Number(i.actualCost) || 0), 0 ); const isOverrun = actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0; return (
{m.name} {statusBadge.label} {priBadge.label} {isOverrun && ( Budget Overrun )}
Due: {formatDate(m.dueAt)} • ID #{m.id}
Milestone Value: {formatZAR(plannedMilestoneValue)}
Evidence
{/* Breakdown of Activities & Indicators */}
Activities: {mActivities.length} • Indicators: {mIndicators.length} • Actual Incurred: plannedMilestoneValue && plannedMilestoneValue > 0 ? 'text-amber-400' : 'text-slate-200'}`}>{formatZAR(actualMilestoneValue)}
{plannedMilestoneValue > 0 && (
Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
)}
); })}
)}
); };