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 { Target, Plus, TrendingUp, X, RotateCcw, AlertTriangle, Briefcase, Filter, Info, ShieldCheck, Coins, } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { HelpTooltip } from '../ui/HelpTooltip'; import { useToast } from '../ui/Toast'; import { getProjectIndicators, createIndicator, getActivities, } from '../../services/api'; import { Project, Indicator, UnitOfMeasure, Activity } from '../../types'; import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS, formatZAR } from '../../lib/formatters'; const indicatorSchema = z.object({ name: z.string().min(4, 'Indicator delivery specification required (min 4 chars)'), activityId: z.string().optional().nullable(), unitOfMeasure: z.coerce.number().min(1), baselineAmount: z.coerce.number().min(0, 'Baseline must be >= 0'), targetAmount: z.coerce.number().min(1, 'Target must be > 0'), targetCost: z.coerce.number().min(0, 'Target cost must be >= 0').optional(), }); type IndicatorFormValues = z.infer; interface ProjectIndicatorsModalProps { isOpen: boolean; onClose: () => void; project: Project | null; initialActivityId?: string | number | null; } export const ProjectIndicatorsModal: React.FC = ({ isOpen, onClose, project, initialActivityId = null, }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const [isFormOpen, setIsFormOpen] = useState(false); const [selectedActivityFilter, setSelectedActivityFilter] = useState('all'); const projectId = project?.id; const { data: indicators = [], isLoading: isLoadingIndicators, refetch, } = useQuery({ queryKey: ['indicators', projectId], queryFn: () => (projectId ? getProjectIndicators(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 form = useForm({ resolver: zodResolver(indicatorSchema), defaultValues: { name: '', activityId: initialActivityId ? String(initialActivityId) : '', unitOfMeasure: UnitOfMeasure.Count, baselineAmount: 0, targetAmount: 100, targetCost: 0, }, }); const createMutation = useMutation({ mutationFn: async (data: IndicatorFormValues) => { if (!projectId) return; const parsedActivityId = data.activityId && String(data.activityId).trim() !== '' ? data.activityId : null; return createIndicator({ projectId: projectId, activityId: parsedActivityId, name: data.name, unitOfMeasure: Number(data.unitOfMeasure), baselineAmount: Number(data.baselineAmount), targetAmount: Number(data.targetAmount), targetCost: Number(data.targetCost) || 0, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['indicators', projectId] }); queryClient.invalidateQueries({ queryKey: ['indicators'] }); queryClient.invalidateQueries({ queryKey: ['activities', projectId] }); success('Delivery Indicator Created', 'Indicator specification linked to work activity'); setIsFormOpen(false); form.reset(); }, onError: (err: unknown) => { toastError('Failed to create indicator', err); }, }); // Filter indicators by activity const filteredIndicators = useMemo(() => { if (selectedActivityFilter === 'all') return indicators; if (selectedActivityFilter === 'unlinked') return indicators.filter((i) => !i.activityId); return indicators.filter((i) => String(i.activityId) === String(selectedActivityFilter)); }, [indicators, selectedActivityFilter]); // Aggregate totals const totalTargetCost = useMemo( () => indicators.reduce((sum, i) => sum + (Number(i.targetCost) || 0), 0), [indicators] ); const totalActualCost = useMemo( () => indicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0), [indicators] ); if (!project) return null; return ( { setIsFormOpen(false); onClose(); }} title={`M&E Delivery Indicators: ${project.name}`} subtitle={`Project #${project.id} • Activity Delivery Specifications & Evidence Requirements`} helpText="Each indicator specifies an expected delivery output linked to an operational activity. Quantities and material costs are captured as actuals by the partner in the Partner Portal." maxWidth="xl" >
{/* KPI Summary Cards */}
Delivery Indicators
{indicators.length} specifications
Target Delivery Cost
{formatZAR(totalTargetCost)}
Partner Actuals Incurred
totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}> {formatZAR(totalActualCost)}
{/* Action Header & Activity Filter Bar */}
Filter by Activity:
{!isFormOpen && ( )}
{/* Indicator Create Form */} {isFormOpen && (
Define Activity Delivery Indicator & Evidence Expectation
createMutation.mutate(data))} className="space-y-3">
{form.formState.errors.name && (

{form.formState.errors.name.message}

)}

Linking to an activity establishes the causal delivery chain from planned task to substantiated output.

Estimated monetary cost for delivering this indicator.

)} {/* Notice Card Explaining Partner Action */}
Maker-Checker Operational Notice: ConsoleUI enables Project Managers to establish output specifications and expected delivery costs. Actual item quantities, material expenditures, and verification evidence documents are recorded by the Grantee/Partner via the PartnerUI Portal.
{/* Indicators List */}
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
{isLoadingIndicators || isLoadingActivities ? (
Loading indicators...
) : filteredIndicators.length === 0 ? (

No indicators matching selection

Define delivery output specifications and link them to operational activities.

) : (
{filteredIndicators.map((ind) => { const target = Number(ind.targetAmount) || 1; const actual = Number(ind.actualAmount) || 0; const baseline = Number(ind.baselineAmount) || 0; const targetCost = Number(ind.targetCost) || 0; const actualCost = Number(ind.actualCost) || 0; const pct = Math.round((actual / target) * 100); const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId)); const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount); return (
{ind.name} {getUnitOfMeasureLabel(ind.unitOfMeasure)} {parentActivity ? ( {parentActivity.title} ) : ( General Project Indicator )}
Indicator ID #{ind.id} {ind.activityId && ` • Activity #${ind.activityId}`}
{isOverrun && ( Cost Overrun )} = 100 ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' : pct >= 50 ? 'bg-blue-500/10 text-blue-400 border-blue-500/30' : 'bg-amber-500/10 text-amber-400 border-amber-500/30' }`} > {pct}% Delivered Partner Verified
{/* Deliverable Units Progress Bar */}
= 100 ? 'bg-emerald-500' : pct >= 50 ? 'bg-gradient-to-r from-blue-500 to-indigo-500' : 'bg-amber-500' }`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
Baseline: {baseline} Partner Actuals Reported:{' '} {actual} Target: {target}
{/* Financial Cost Specification & Actuals Row */}
Target Cost: {formatZAR(targetCost)} Actual Material Cost:{' '} targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}> {formatZAR(actualCost)} {targetCost > 0 && ( Variance: {formatZAR(targetCost - actualCost)} )}
{isOverrun && (
Cost exceeds delivery allocation; subject to project variation approval (M15).
)}
); })}
)}
); };