580 lines
25 KiB
TypeScript
580 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 {
|
|
Briefcase,
|
|
Plus,
|
|
Edit2,
|
|
Clock,
|
|
Coins,
|
|
FileCheck,
|
|
Flag,
|
|
X,
|
|
RotateCcw,
|
|
Target,
|
|
AlertTriangle,
|
|
ChevronRight,
|
|
ShieldAlert,
|
|
} from 'lucide-react';
|
|
import { Modal } from '../ui/Modal';
|
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
|
import { useToast } from '../ui/Toast';
|
|
import {
|
|
getActivities,
|
|
createActivity,
|
|
updateActivity,
|
|
getMilestones,
|
|
getProjectIndicators,
|
|
getBudgets,
|
|
} from '../../services/api';
|
|
import { Project, Activity, Milestone, Indicator, Budget } from '../../types';
|
|
import { formatDate, formatZAR } from '../../lib/formatters';
|
|
|
|
const activitySchema = z.object({
|
|
title: z.string().min(3, 'Activity title required'),
|
|
description: z.string().min(5, 'Description required'),
|
|
category: z.string().min(2, 'Category required'),
|
|
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
|
milestoneId: z.string().optional().nullable(),
|
|
performedAt: z.string().optional().nullable(),
|
|
});
|
|
|
|
type ActivityFormValues = z.infer<typeof activitySchema>;
|
|
|
|
interface ProjectActivitiesModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
project: Project | null;
|
|
onOpenIndicatorsForActivity?: (activityId: string | number) => void;
|
|
}
|
|
|
|
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
project,
|
|
onOpenIndicatorsForActivity,
|
|
}) => {
|
|
const queryClient = useQueryClient();
|
|
const { success, error: toastError } = useToast();
|
|
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [editingActivity, setEditingActivity] = useState<Activity | null>(null);
|
|
|
|
const projectId = project?.id;
|
|
|
|
const {
|
|
data: activities = [],
|
|
isLoading,
|
|
refetch,
|
|
} = useQuery({
|
|
queryKey: ['activities', projectId],
|
|
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: milestones = [] } = useQuery({
|
|
queryKey: ['milestones', projectId],
|
|
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: indicators = [] } = 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,
|
|
});
|
|
|
|
// Approved project budget calculations
|
|
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]);
|
|
|
|
const totalAllocated = useMemo(
|
|
() => activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0),
|
|
[activities]
|
|
);
|
|
|
|
const maxAllowedForForm = useMemo(() => {
|
|
if (approvedBudgetTotal <= 0) return Infinity;
|
|
const baseRemaining = approvedBudgetTotal - totalAllocated;
|
|
if (editingActivity) {
|
|
return baseRemaining + (Number(editingActivity.amount) || 0);
|
|
}
|
|
return Math.max(0, baseRemaining);
|
|
}, [approvedBudgetTotal, totalAllocated, editingActivity]);
|
|
|
|
const remainingBudget = useMemo(() => {
|
|
if (approvedBudgetTotal <= 0) return 0;
|
|
return Math.max(0, approvedBudgetTotal - totalAllocated);
|
|
}, [approvedBudgetTotal, totalAllocated]);
|
|
|
|
const form = useForm<ActivityFormValues>({
|
|
resolver: zodResolver(activitySchema),
|
|
defaultValues: {
|
|
title: '',
|
|
description: '',
|
|
category: 'Operations',
|
|
amount: 0,
|
|
milestoneId: '',
|
|
performedAt: new Date().toISOString().slice(0, 10),
|
|
},
|
|
});
|
|
|
|
const openCreateForm = () => {
|
|
setEditingActivity(null);
|
|
form.reset({
|
|
title: '',
|
|
description: '',
|
|
category: 'Operations',
|
|
amount: 0,
|
|
milestoneId: '',
|
|
performedAt: new Date().toISOString().slice(0, 10),
|
|
});
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const openEditForm = (act: Activity) => {
|
|
setEditingActivity(act);
|
|
form.reset({
|
|
title: act.title || '',
|
|
description: act.description || '',
|
|
category: act.category || 'Operations',
|
|
amount: act.amount || 0,
|
|
milestoneId: act.milestoneId ? String(act.milestoneId) : '',
|
|
performedAt: act.performedAt ? act.performedAt.slice(0, 10) : '',
|
|
});
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (data: ActivityFormValues): Promise<void> => {
|
|
if (!projectId) return;
|
|
|
|
const requestedAmount = Number(data.amount);
|
|
if (approvedBudgetTotal > 0 && requestedAmount > maxAllowedForForm) {
|
|
throw new Error(
|
|
`Activity amount (${formatZAR(requestedAmount)}) exceeds the available remaining project budget (${formatZAR(maxAllowedForForm)}).`
|
|
);
|
|
}
|
|
|
|
const parsedMilestoneId =
|
|
data.milestoneId && String(data.milestoneId).trim() !== ''
|
|
? Number(data.milestoneId)
|
|
: null;
|
|
const performedAtIso = data.performedAt ? new Date(data.performedAt).toISOString() : null;
|
|
|
|
if (editingActivity) {
|
|
await updateActivity({
|
|
activityId: editingActivity.id,
|
|
projectId: projectId,
|
|
title: data.title,
|
|
description: data.description,
|
|
category: data.category,
|
|
amount: requestedAmount,
|
|
milestoneId: parsedMilestoneId,
|
|
performedAt: performedAtIso,
|
|
});
|
|
} else {
|
|
await createActivity({
|
|
projectId: projectId,
|
|
title: data.title,
|
|
description: data.description,
|
|
category: data.category,
|
|
amount: requestedAmount,
|
|
milestoneId: parsedMilestoneId,
|
|
performedAt: performedAtIso,
|
|
});
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['activities', projectId] });
|
|
queryClient.invalidateQueries({ queryKey: ['milestones', projectId] });
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
success(
|
|
editingActivity ? 'Activity Updated' : 'Activity Created',
|
|
editingActivity
|
|
? 'Work breakdown task updated successfully'
|
|
: 'Task added to project work breakdown structure'
|
|
);
|
|
setIsFormOpen(false);
|
|
setEditingActivity(null);
|
|
form.reset();
|
|
},
|
|
onError: (err: unknown) => {
|
|
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
|
|
},
|
|
});
|
|
|
|
if (!project) return null;
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={() => {
|
|
setIsFormOpen(false);
|
|
setEditingActivity(null);
|
|
onClose();
|
|
}}
|
|
title={`Work Breakdown Activities: ${project.name}`}
|
|
subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`}
|
|
helpText="Under each milestone, operational activities are scheduled with budgeted amounts. The sum of all activity budgets across the project cannot exceed the approved project budget."
|
|
maxWidth="xl"
|
|
>
|
|
<div className="space-y-5">
|
|
{/* 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">
|
|
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
|
Total Allocated Activities
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalAllocated)}</span>
|
|
<span className="text-[11px] text-slate-400">({activities.length} tasks)</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-emerald-400" />
|
|
Approved Project Budget
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</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-blue-400" />
|
|
Remaining Budget Ceiling
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className={`text-xl font-bold font-mono ${remainingBudget <= 0 && approvedBudgetTotal > 0 ? 'text-rose-400' : 'text-blue-400'}`}>
|
|
{formatZAR(remainingBudget)}
|
|
</span>
|
|
</div>
|
|
</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">
|
|
Scheduled Tasks: <strong className="text-white">{activities.length}</strong>
|
|
</span>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{!isFormOpen && (
|
|
<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-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 rounded-xl shadow-md shadow-cyan-600/20 transition cursor-pointer"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
Add Activity
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create/Edit Activity Form */}
|
|
{isFormOpen && (
|
|
<div className="bg-[#0d121f] p-4 rounded-xl border border-cyan-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-cyan-400 flex items-center gap-1.5">
|
|
<Briefcase className="w-3.5 h-3.5" />
|
|
{editingActivity ? `Edit Activity #${editingActivity.id}` : 'Add Work Breakdown Activity'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsFormOpen(false);
|
|
setEditingActivity(null);
|
|
}}
|
|
className="text-slate-400 hover:text-slate-200 text-xs p-1"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{approvedBudgetTotal > 0 && (
|
|
<div className="p-2.5 rounded-lg bg-blue-500/10 border border-blue-500/25 text-xs flex items-center justify-between text-blue-300">
|
|
<span>Maximum activity budget allowance:</span>
|
|
<strong className="font-mono">{formatZAR(maxAllowedForForm)}</strong>
|
|
</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">Activity Title *</label>
|
|
<input
|
|
type="text"
|
|
{...form.register('title')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white bg-[#090d16]"
|
|
placeholder="e.g. Field Soil Sensor Installation & Pilot Calibration"
|
|
/>
|
|
{form.formState.errors.title && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.title.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
|
Associated Milestone (Deliverable Link)
|
|
</label>
|
|
<select
|
|
{...form.register('milestoneId')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 bg-[#090d16] text-white font-medium"
|
|
>
|
|
<option value="">-- No Milestone / Standalone Task --</option>
|
|
{milestones.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
Milestone #{m.id}: {m.name} (Due: {formatDate(m.dueAt)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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">Budget Amount (ZAR) *</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
{...form.register('amount')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white font-mono bg-[#090d16]"
|
|
/>
|
|
{form.formState.errors.amount && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.amount.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Category *</label>
|
|
<input
|
|
type="text"
|
|
{...form.register('category')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white bg-[#090d16]"
|
|
placeholder="Operations / Hardware / Testing"
|
|
/>
|
|
{form.formState.errors.category && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.category.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Execution / Performed Date</label>
|
|
<input
|
|
type="date"
|
|
{...form.register('performedAt')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white bg-[#090d16]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
|
|
<textarea
|
|
rows={2}
|
|
{...form.register('description')}
|
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-cyan-500 text-white bg-[#090d16]"
|
|
placeholder="Task specifications, methodology, and expected output..."
|
|
/>
|
|
{form.formState.errors.description && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.description.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsFormOpen(false);
|
|
setEditingActivity(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-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 rounded-xl shadow-md shadow-cyan-500/20 cursor-pointer"
|
|
>
|
|
{mutation.isPending ? 'Saving...' : editingActivity ? 'Save Changes' : 'Add Activity'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Activities List */}
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
|
|
<span>Work Breakdown Tasks ({activities.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 work breakdown activities...
|
|
</div>
|
|
) : activities.length === 0 ? (
|
|
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
|
|
<Briefcase className="w-8 h-8 text-slate-600 mx-auto" />
|
|
<p className="text-xs font-semibold text-slate-300">No work activities recorded yet</p>
|
|
<p className="text-[11px] text-slate-500">
|
|
Deconstruct the project milestones into operational tasks with allocated budgets.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={openCreateForm}
|
|
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:underline"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" /> Add first activity
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 max-h-[420px] overflow-y-auto pr-1">
|
|
{activities.map((a) => {
|
|
const linkedMilestone = a.milestoneId
|
|
? milestones.find((m) => String(m.id) === String(a.milestoneId))
|
|
: null;
|
|
|
|
const actIndicators = indicators.filter((i) => String(i.activityId) === String(a.id));
|
|
const actActualCost = actIndicators.reduce((sum, i) => sum + (Number(i.actualCost) || 0), 0);
|
|
const isOverrun = actActualCost > Number(a.amount) && a.amount > 0;
|
|
|
|
return (
|
|
<div
|
|
key={a.id}
|
|
className={`p-3.5 rounded-xl border bg-[#0d121f] transition space-y-2.5 ${
|
|
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">{a.title}</span>
|
|
<span className="bg-slate-900 border border-slate-700 text-slate-300 px-2 py-0.5 rounded-md text-[10px] font-medium">
|
|
{a.category}
|
|
</span>
|
|
{a.milestoneId && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/25">
|
|
<Flag className="w-2.5 h-2.5" />
|
|
Milestone #{a.milestoneId}
|
|
{linkedMilestone ? ` (${linkedMilestone.name})` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-400 leading-relaxed">{a.description}</p>
|
|
<div className="flex items-center gap-3 text-[11px] text-slate-500 font-mono pt-1">
|
|
<span>Date: {formatDate(a.performedAt)}</span>
|
|
<span>•</span>
|
|
<span>ID #{a.id}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
|
|
<div className="text-right mr-1">
|
|
<span className="block font-mono text-xs font-bold text-cyan-400">
|
|
{formatZAR(a.amount)}
|
|
</span>
|
|
{actActualCost > 0 && (
|
|
<span className={`block font-mono text-[10px] ${isOverrun ? 'text-rose-400 font-bold' : 'text-slate-400'}`}>
|
|
Actual: {formatZAR(actActualCost)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<Link
|
|
to={`/monitoring?tab=evidence&projectId=${project.id}&activityId=${a.id}`}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-lg transition"
|
|
title="Inspect activity evidence"
|
|
>
|
|
<FileCheck className="w-3.5 h-3.5 text-cyan-400" />
|
|
Evidence
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditForm(a)}
|
|
className="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition cursor-pointer"
|
|
title="Edit Activity"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Linked Delivery Indicators Section */}
|
|
<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-2 flex-wrap">
|
|
<span className="text-[11px] text-slate-400 flex items-center gap-1">
|
|
<Target className="w-3 h-3 text-indigo-400" />
|
|
Delivery Specifications:
|
|
</span>
|
|
{actIndicators.length === 0 ? (
|
|
<span className="text-[11px] text-slate-500 italic">No indicators defined for this activity yet.</span>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{actIndicators.map((ind) => (
|
|
<span
|
|
key={ind.id}
|
|
className="px-2 py-0.5 rounded bg-slate-900 border border-slate-700/70 text-[10px] text-slate-300 font-mono"
|
|
>
|
|
{ind.name}: {ind.actualAmount}/{ind.targetAmount}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{isOverrun && (
|
|
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded border border-rose-500/25">
|
|
⚠️ Overrun: +{formatZAR(actActualCost - Number(a.amount))}
|
|
</span>
|
|
)}
|
|
|
|
{onOpenIndicatorsForActivity && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenIndicatorsForActivity(a.id)}
|
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300 hover:underline cursor-pointer"
|
|
>
|
|
<span>Manage Indicators ({actIndicators.length})</span>
|
|
<ChevronRight className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|