Refactored projects and milestones page

This commit is contained in:
2026-09-28 11:34:11 +02:00
parent 93b3416d2c
commit 8b68f7b99c
8 changed files with 3307 additions and 1185 deletions
@@ -0,0 +1,396 @@
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 {
Target,
Plus,
TrendingUp,
X,
RotateCcw,
Check,
Edit3,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import {
getProjectIndicators,
createIndicator,
recordIndicatorActual,
} from '../../services/api';
import { Project, Indicator, UnitOfMeasure } from '../../types';
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters';
const indicatorSchema = z.object({
name: z.string().min(5, 'Indicator definition required (min 5 chars)'),
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'),
});
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
interface ProjectIndicatorsModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
}
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
isOpen,
onClose,
project,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false);
const [recordingActualForId, setRecordingActualForId] = useState<string | number | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const projectId = project?.id;
const {
data: indicators = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema) as any,
defaultValues: {
name: '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
},
});
const createMutation = useMutation({
mutationFn: async (data: IndicatorFormValues) => {
if (!projectId) return;
return createIndicator({
projectId: projectId,
name: data.name,
unitOfMeasure: Number(data.unitOfMeasure),
baselineAmount: Number(data.baselineAmount),
targetAmount: Number(data.targetAmount),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Indicator Created', 'M&E Key Performance Indicator added to project');
setIsFormOpen(false);
form.reset();
},
onError: (err: unknown) => {
toastError('Failed to create indicator', err);
},
});
const actualMutation = useMutation({
mutationFn: async ({ indicatorId, actualAmount }: { indicatorId: string | number; actualAmount: number }) => {
return recordIndicatorActual(indicatorId, actualAmount);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators', projectId] });
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Actual Value Logged', 'M&E progress measurement updated successfully');
setRecordingActualForId(null);
},
onError: (err: unknown) => {
toastError('Failed to record actual value', err);
},
});
if (!project) return null;
return (
<Modal
isOpen={isOpen}
onClose={() => {
setIsFormOpen(false);
setRecordingActualForId(null);
onClose();
}}
title={`M&E Performance Indicators: ${project.name}`}
subtitle={`Project #${project.id} • Results & Impact Framework`}
helpText="Quantitative indicators monitor baseline vs. target deliverables (e.g. jobs created, patents filed, rural SMMEs trained, renewable MW generated)."
maxWidth="xl"
>
<div className="space-y-5">
{/* KPI Summary Card */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5 text-indigo-400" />
Monitoring & Evaluation Targets
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{indicators.length}</span>
<span className="text-xs text-slate-400">Tracked performance metrics</span>
</div>
</div>
<div className="flex items-center gap-2">
{!isFormOpen && (
<button
type="button"
onClick={() => {
form.reset();
setIsFormOpen(true);
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-600/20 transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Indicator
</button>
)}
</div>
</div>
{/* Create Indicator Form */}
{isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-indigo-500/30 space-y-4 animate-in fade-in">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
<span className="text-xs font-bold text-indigo-400 flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" />
Define New Key Performance Indicator (KPI)
</span>
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="text-slate-400 hover:text-slate-200 text-xs p-1"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data as any))} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
<input
type="text"
{...form.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-indigo-500 text-white bg-[#090d16]"
placeholder="e.g. Number of Smallholder Farmers Trained in Precision Irrigation"
/>
{form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
<select
{...form.register('unitOfMeasure')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white font-medium"
>
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label>
<input
type="number"
{...form.register('baselineAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Amount *</label>
<input
type="number"
{...form.register('targetAmount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-indigo-500 text-white font-mono bg-[#090d16]"
/>
{form.formState.errors.targetAmount && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.targetAmount.message}</p>
)}
</div>
</div>
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 rounded-xl shadow-md shadow-indigo-500/20 cursor-pointer"
>
{createMutation.isPending ? 'Saving...' : 'Add Indicator'}
</button>
</div>
</form>
</div>
)}
{/* Indicators List */}
<div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
<span>Performance Target Registry ({indicators.length})</span>
<button
type="button"
onClick={() => refetch()}
className="text-[11px] text-slate-400 hover:text-white flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3 h-3" /> Refresh
</button>
</div>
{isLoading ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading indicators...
</div>
) : indicators.length === 0 ? (
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<Target className="w-8 h-8 text-slate-600 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No M&E indicators defined yet</p>
<p className="text-[11px] text-slate-500">Track quantitative impact metrics against established baselines.</p>
<button
type="button"
onClick={() => setIsFormOpen(true)}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-indigo-400 hover:underline"
>
<Plus className="w-3.5 h-3.5" /> Add first indicator
</button>
</div>
) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
{indicators.map((ind) => {
const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0;
const baseline = Number(ind.baselineAmount) || 0;
const pct = Math.round((actual / target) * 100);
const isRecordingThis = recordingActualForId === ind.id;
return (
<div
key={ind.id}
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{ind.name}</span>
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px]">
{getUnitOfMeasureLabel(ind.unitOfMeasure)}
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
pct >= 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}% Achieved
</span>
{!isRecordingThis ? (
<button
type="button"
onClick={() => {
setRecordingActualForId(ind.id);
setActualInput(actual);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/25 px-2.5 py-1 rounded-lg transition cursor-pointer"
>
<Edit3 className="w-3 h-3 text-indigo-400" />
Record Actual
</button>
) : (
<button
type="button"
onClick={() => setRecordingActualForId(null)}
className="text-slate-400 hover:text-slate-200 text-xs p-1"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Progress Bar */}
<div className="space-y-1">
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
className={`h-full rounded-full transition-all duration-300 ${
pct >= 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))}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong></span>
<span>Actual: <strong className="text-white font-mono font-bold">{actual}</strong></span>
<span>Target: <strong className="text-indigo-400 font-mono">{target}</strong></span>
</div>
</div>
{/* Inline Record Actual Form */}
{isRecordingThis && (
<div className="pt-2 border-t border-slate-800 flex items-center justify-between gap-3 animate-in fade-in">
<span className="text-[11px] font-semibold text-slate-300">Update Actual Value:</span>
<div className="flex items-center gap-2">
<input
type="number"
value={actualInput}
onChange={(e) => setActualInput(Number(e.target.value))}
className="w-24 px-2 py-1 text-xs border border-slate-700 rounded-lg text-white font-mono bg-[#090d16] focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
disabled={actualMutation.isPending}
onClick={() =>
actualMutation.mutate({
indicatorId: ind.id,
actualAmount: actualInput,
})
}
className="px-3 py-1 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg shadow-sm transition cursor-pointer"
>
{actualMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
};