Enforced relationships between activities and indicators, fixed actuals recording
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
TrendingUp,
|
||||
X,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Edit3,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
Filter,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
@@ -18,16 +22,18 @@ import { useToast } from '../ui/Toast';
|
||||
import {
|
||||
getProjectIndicators,
|
||||
createIndicator,
|
||||
recordIndicatorActual,
|
||||
getActivities,
|
||||
} from '../../services/api';
|
||||
import { Project, Indicator, UnitOfMeasure } from '../../types';
|
||||
import { getUnitOfMeasureLabel, UNIT_OF_MEASURE_OPTIONS } from '../../lib/formatters';
|
||||
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(5, 'Indicator definition required (min 5 chars)'),
|
||||
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<typeof indicatorSchema>;
|
||||
@@ -36,25 +42,26 @@ interface ProjectIndicatorsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project | null;
|
||||
initialActivityId?: string | number | null;
|
||||
}
|
||||
|
||||
export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
initialActivityId = null,
|
||||
}) => {
|
||||
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 [selectedActivityFilter, setSelectedActivityFilter] = useState<string>('all');
|
||||
|
||||
const projectId = project?.id;
|
||||
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading,
|
||||
isLoading: isLoadingIndicators,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators', projectId],
|
||||
@@ -62,31 +69,49 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: activities = [],
|
||||
isLoading: isLoadingActivities,
|
||||
} = useQuery({
|
||||
queryKey: ['activities', projectId],
|
||||
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
||||
enabled: isOpen && !!projectId,
|
||||
});
|
||||
|
||||
const form = useForm<IndicatorFormValues>({
|
||||
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'] });
|
||||
success('Indicator Created', 'M&E Key Performance Indicator added to project');
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
||||
success('Delivery Indicator Created', 'Indicator specification linked to work activity');
|
||||
setIsFormOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
@@ -95,20 +120,22 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
// 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;
|
||||
|
||||
@@ -117,34 +144,86 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
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)."
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{/* KPI Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<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
|
||||
Delivery Indicators
|
||||
</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 className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold text-white font-mono">{indicators.length}</span>
|
||||
<span className="text-[11px] text-slate-400">specifications</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Target Delivery Cost
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalTargetCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Partner Actuals Incurred
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className={`text-xl font-bold font-mono ${totalActualCost > totalTargetCost && totalTargetCost > 0 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{formatZAR(totalActualCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Header & Activity Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">Filter by Activity:</span>
|
||||
<select
|
||||
value={selectedActivityFilter}
|
||||
onChange={(e) => setSelectedActivityFilter(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-slate-700/80 rounded-lg focus:outline-none focus:ring-1 focus:ring-indigo-500 bg-[#090d16] text-white max-w-xs truncate"
|
||||
>
|
||||
<option value="all">All Activities ({indicators.length})</option>
|
||||
{activities.map((act) => {
|
||||
const count = indicators.filter((i) => String(i.activityId) === String(act.id)).length;
|
||||
return (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} ({count} indicators)
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<option value="unlinked">Unlinked General Indicators</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
form.reset({
|
||||
name: '',
|
||||
activityId: selectedActivityFilter !== 'all' && selectedActivityFilter !== 'unlinked' ? selectedActivityFilter : '',
|
||||
unitOfMeasure: UnitOfMeasure.Count,
|
||||
baselineAmount: 0,
|
||||
targetAmount: 100,
|
||||
targetCost: 0,
|
||||
});
|
||||
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"
|
||||
@@ -156,13 +235,13 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Indicator Form */}
|
||||
{/* Indicator Create 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)
|
||||
Define Activity Delivery Indicator & Evidence Expectation
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -175,19 +254,41 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
|
||||
<form onSubmit={form.handleSubmit((data) => createMutation.mutate(data))} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Indicator Definition *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Indicator Name / Delivery Output Specification *
|
||||
</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"
|
||||
placeholder="e.g. Number of solar microgrids commissioned and tested"
|
||||
/>
|
||||
{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">
|
||||
Parent Activity (Required Delivery Link) *
|
||||
</label>
|
||||
<select
|
||||
{...form.register('activityId')}
|
||||
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"
|
||||
>
|
||||
<option value="">-- Select Parent Activity --</option>
|
||||
{activities.map((act) => (
|
||||
<option key={act.id} value={String(act.id)}>
|
||||
{act.title} (Budget: {formatZAR(act.amount)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Linking to an activity establishes the causal delivery chain from planned task to substantiated output.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Unit of Measure *</label>
|
||||
<select
|
||||
@@ -203,32 +304,47 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Amount *</label>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Expected Delivery Cost (Target Cost ZAR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register('targetCost')}
|
||||
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]"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Estimated monetary cost for delivering this indicator.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Baseline Metric *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Metric *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
{...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">
|
||||
<div className="flex items-center justify-end gap-2 pt-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"
|
||||
className="px-3 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -237,17 +353,27 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
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'}
|
||||
{createMutation.isPending ? 'Saving...' : 'Define Indicator'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notice Card Explaining Partner Action */}
|
||||
<div className="bg-slate-900/60 p-3 rounded-xl border border-slate-800 flex items-start gap-2.5 text-slate-400 text-[11px] leading-relaxed">
|
||||
<Info className="w-4 h-4 text-cyan-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-slate-200">Maker-Checker Operational Notice:</strong> 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 <strong className="text-cyan-400 font-mono">PartnerUI Portal</strong>.
|
||||
</div>
|
||||
</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>
|
||||
<span>
|
||||
Defined Delivery Specifications ({filteredIndicators.length} of {indicators.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
@@ -257,49 +383,77 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{isLoadingIndicators || isLoadingActivities ? (
|
||||
<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 ? (
|
||||
) : filteredIndicators.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>
|
||||
<p className="text-xs font-semibold text-slate-300">No indicators matching selection</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Define delivery output specifications and link them to operational activities.
|
||||
</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
|
||||
<Plus className="w-3.5 h-3.5" /> Define first indicator
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
|
||||
{indicators.map((ind) => {
|
||||
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
||||
{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 isRecordingThis = recordingActualForId === ind.id;
|
||||
|
||||
const parentActivity = activities.find((a) => String(a.id) === String(ind.activityId));
|
||||
const isOverrun = (actualCost > targetCost && targetCost > 0) || (parentActivity && actualCost > parentActivity.amount);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ind.id}
|
||||
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-3"
|
||||
className={`p-4 rounded-xl border bg-[#0d121f] transition space-y-3 ${
|
||||
isOverrun ? 'border-amber-500/40 bg-amber-950/10' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
{parentActivity ? (
|
||||
<span className="bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 px-2 py-0.5 rounded-md text-[10px] flex items-center gap-1">
|
||||
<Briefcase className="w-2.5 h-2.5" />
|
||||
{parentActivity.title}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md text-[10px]">
|
||||
General Project Indicator
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
Indicator ID #{ind.id}
|
||||
{ind.activityId && ` • Activity #${ind.activityId}`}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Indicator ID #{ind.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOverrun && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Cost Overrun
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`text-xs font-bold px-2 py-0.5 rounded-md border ${
|
||||
pct >= 100
|
||||
@@ -309,34 +463,20 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
{pct}% Achieved
|
||||
{pct}% Delivered
|
||||
</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>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900/90 border border-slate-700/80 px-2 py-0.5 rounded-md"
|
||||
title="Actuals are recorded by the partner in PartnerUI"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 text-cyan-400" />
|
||||
Partner Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{/* Deliverable Units 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
|
||||
@@ -351,39 +491,44 @@ export const ProjectIndicatorsModal: React.FC<ProjectIndicatorsModalProps> = ({
|
||||
/>
|
||||
</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>
|
||||
<span>
|
||||
Baseline: <strong className="text-slate-300 font-mono">{baseline}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Partner Actuals Reported:{' '}
|
||||
<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>
|
||||
{/* Financial Cost Specification & Actuals Row */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-4 text-slate-400 text-[11px]">
|
||||
<span>
|
||||
Target Cost: <strong className="text-cyan-400 font-mono">{formatZAR(targetCost)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Actual Material Cost:{' '}
|
||||
<strong className={`font-mono font-bold ${actualCost > targetCost && targetCost > 0 ? 'text-amber-400' : 'text-white'}`}>
|
||||
{formatZAR(actualCost)}
|
||||
</strong>
|
||||
</span>
|
||||
{targetCost > 0 && (
|
||||
<span className="text-slate-500">
|
||||
Variance: {formatZAR(targetCost - actualCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOverrun && (
|
||||
<div className="text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
Cost exceeds delivery allocation; subject to project variation approval (M15).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user