577 lines
25 KiB
TypeScript
577 lines
25 KiB
TypeScript
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<typeof milestoneSchema>;
|
|
|
|
interface ProjectMilestonesModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
project: Project | null;
|
|
}
|
|
|
|
export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|
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<Milestone | null>(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<MilestoneFormValues>({
|
|
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<void> => {
|
|
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 (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={() => {
|
|
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"
|
|
>
|
|
<div className="space-y-5">
|
|
{/* KPI Cards: Project Value Roll-Up & Performance */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
|
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
|
<div className="flex items-center justify-between gap-1 mb-1">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
|
<Coins className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
|
Total Project Value
|
|
</span>
|
|
</div>
|
|
<div className="my-1.5">
|
|
<div
|
|
className="text-lg sm:text-xl font-bold font-mono text-blue-400 truncate tracking-tight"
|
|
title={formatZAR(totalProjectMilestonesValue)}
|
|
>
|
|
{formatZAR(totalProjectMilestonesValue)}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 truncate">Sum of all milestones</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
|
<div className="flex items-center justify-between gap-1 mb-1">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
|
<Coins className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
Approved Budget
|
|
</span>
|
|
</div>
|
|
<div className="my-1.5">
|
|
<div
|
|
className="text-lg sm:text-xl font-bold font-mono text-emerald-400 truncate tracking-tight"
|
|
title={formatZAR(approvedBudgetTotal)}
|
|
>
|
|
{formatZAR(approvedBudgetTotal)}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 truncate">Allocated budget ceiling</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
|
<div className="flex items-center justify-between gap-1 mb-1">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
|
<TrendingUp className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
|
Partner Actuals
|
|
</span>
|
|
</div>
|
|
<div className="my-1.5">
|
|
<div
|
|
className="text-lg sm:text-xl font-bold font-mono text-cyan-400 truncate tracking-tight"
|
|
title={formatZAR(totalProjectActuals)}
|
|
>
|
|
{formatZAR(totalProjectActuals)}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 truncate">Incurred material costs</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
|
<div className="flex items-center justify-between gap-1 mb-1">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
|
Milestone Progress
|
|
</span>
|
|
</div>
|
|
<div className="my-1.5">
|
|
<div className="text-lg sm:text-xl font-bold text-white font-mono truncate tracking-tight">
|
|
{progressPct}%
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 truncate">
|
|
{verifiedCount} of {milestones.length} verified
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Header */}
|
|
<div className="flex items-center justify-between bg-[#0d121f] p-3 rounded-xl border border-slate-800">
|
|
<span className="text-xs text-slate-400">
|
|
Deliverable Milestones: <strong className="text-white">{milestones.length}</strong>
|
|
</span>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{!isFormOpen && canAdd('projects_milestones') && (
|
|
<button
|
|
type="button"
|
|
onClick={openCreateForm}
|
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
Add Milestone
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Milestone Create/Edit Form */}
|
|
{isFormOpen && (
|
|
<div className="bg-[#0d121f] p-4 rounded-xl border border-blue-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-blue-400 flex items-center gap-1.5">
|
|
<Flag className="w-3.5 h-3.5" />
|
|
{editingMilestone ? `Edit Milestone #${editingMilestone.id}` : 'Define New Contractual Milestone'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsFormOpen(false);
|
|
setEditingMilestone(null);
|
|
}}
|
|
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) => mutation.mutate(data))} className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
|
Deliverable Name / Milestone Title *
|
|
</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-blue-500 text-white bg-[#090d16]"
|
|
placeholder="e.g. M2: Prototype Pilot Testing & Verification Report"
|
|
/>
|
|
{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-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Due Date *</label>
|
|
<input
|
|
type="date"
|
|
{...form.register('dueAt')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
|
|
/>
|
|
{form.formState.errors.dueAt && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.dueAt.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority Level *</label>
|
|
<select
|
|
{...form.register('priority')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
|
>
|
|
{PRIORITY_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{editingMilestone && (
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval Status</label>
|
|
<select
|
|
{...form.register('status')}
|
|
disabled={!canApprove('projects_milestones') || !canApproveRecord('projects_milestones', editingMilestone.createdBy)}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{editingMilestone && !canApproveRecord('projects_milestones', editingMilestone.createdBy) && (
|
|
<p className="text-[10px] text-amber-400 mt-1 flex items-center gap-1">
|
|
<Lock className="w-3 h-3" />
|
|
Maker-Checker: Cannot verify or approve a milestone that you authored.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsFormOpen(false);
|
|
setEditingMilestone(null);
|
|
}}
|
|
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={mutation.isPending}
|
|
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
|
|
>
|
|
{mutation.isPending ? 'Saving...' : editingMilestone ? 'Save Changes' : 'Create Milestone'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Milestones List */}
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
|
|
<span>Project Deliverables Schedule ({milestones.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>
|
|
|
|
{isLoadingMilestones || isLoadingActivities || isLoadingIndicators ? (
|
|
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
|
|
Loading milestones...
|
|
</div>
|
|
) : milestones.length === 0 ? (
|
|
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
|
<Flag className="w-8 h-8 text-slate-600 mx-auto" />
|
|
<p className="text-xs font-semibold text-slate-300">No milestones registered yet</p>
|
|
<p className="text-[11px] text-slate-500">
|
|
Define contractual deliverables to monitor implementation progress.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={openCreateForm}
|
|
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:underline"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" /> Define first milestone
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
|
{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 (
|
|
<div
|
|
key={m.id}
|
|
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-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs font-bold text-white">{m.name}</span>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
|
{priBadge.label}
|
|
</span>
|
|
{isOverrun && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
|
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
|
Budget Overrun
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[11px] text-slate-400 font-mono">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3 text-slate-500" />
|
|
Due: {formatDate(m.dueAt)}
|
|
</span>
|
|
<span>•</span>
|
|
<span>ID #{m.id}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
|
<div className="text-right mr-2">
|
|
<span className="text-[10px] text-slate-400 block font-sans">Milestone Value:</span>
|
|
<span className="font-mono text-xs font-bold text-blue-400 block">
|
|
{formatZAR(plannedMilestoneValue)}
|
|
</span>
|
|
</div>
|
|
|
|
<Link
|
|
to={`/monitoring?tab=evidence&projectId=${project.id}&milestoneId=${m.id}`}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 rounded-lg transition"
|
|
title="Inspect verification evidence"
|
|
>
|
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
|
Evidence
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditForm(m)}
|
|
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
|
title="Edit Milestone"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breakdown of Activities & Indicators */}
|
|
<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-3 text-slate-400 text-[11px] flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<Briefcase className="w-3 h-3 text-cyan-400" />
|
|
Activities: <strong className="text-white font-mono">{mActivities.length}</strong>
|
|
</span>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<Target className="w-3 h-3 text-indigo-400" />
|
|
Indicators: <strong className="text-white font-mono">{mIndicators.length}</strong>
|
|
</span>
|
|
<span>•</span>
|
|
<span>
|
|
Actual Incurred: <strong className={`font-mono font-bold ${actualMilestoneValue > plannedMilestoneValue && plannedMilestoneValue > 0 ? 'text-amber-400' : 'text-slate-200'}`}>{formatZAR(actualMilestoneValue)}</strong>
|
|
</span>
|
|
</div>
|
|
|
|
{plannedMilestoneValue > 0 && (
|
|
<div className="text-[10px] text-slate-500 font-mono">
|
|
Variance: {formatZAR(plannedMilestoneValue - actualMilestoneValue)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|