Refactored projects and milestones page #119

Merged
khwezi merged 1 commits from finishline into main 2026-09-28 11:34:32 +02:00
8 changed files with 3307 additions and 1185 deletions
@@ -0,0 +1,426 @@
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 { Link } from 'react-router-dom';
import {
Briefcase,
Plus,
Edit2,
Clock,
Coins,
FileCheck,
Flag,
X,
RotateCcw,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import {
getActivities,
createActivity,
updateActivity,
getMilestones,
} from '../../services/api';
import { Project, Activity, Milestone } 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;
}
export const ProjectActivitiesModal: React.FC<ProjectActivitiesModalProps> = ({
isOpen,
onClose,
project,
}) => {
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 form = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema) as any,
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 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: Number(data.amount),
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
} else {
await createActivity({
projectId: projectId,
title: data.title,
description: data.description,
category: data.category,
amount: Number(data.amount),
milestoneId: parsedMilestoneId,
performedAt: performedAtIso,
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', 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;
const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0);
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="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations."
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">
<Coins className="w-3.5 h-3.5 text-cyan-400" />
Total Allocated Work Budget
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold font-mono text-cyan-400">{formatZAR(totalBudget)}</span>
<span className="text-xs text-slate-400">({activities.length} recorded tasks)</span>
</div>
</div>
<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>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} 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"
{...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 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-2 max-h-[380px] overflow-y-auto pr-1">
{activities.map((a) => {
const linkedMilestone = a.milestoneId
? milestones.find((m) => String(m.id) === String(a.milestoneId))
: null;
return (
<div
key={a.id}
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition 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>&bull;</span>
<span>ID #{a.id}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<span className="font-mono text-xs font-bold text-cyan-400 mr-2">{formatZAR(a.amount)}</span>
<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>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,324 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
Flag,
Briefcase,
Target,
ShieldAlert,
FileCheck,
Edit2,
Calendar,
Clock,
Coins,
ChevronRight,
TrendingUp,
} from 'lucide-react';
import {
getActivities,
getMilestones,
getProjectIndicators,
getProjectRisks,
} from '../../services/api';
import { Project, ApprovalStatus } from '../../types';
import {
formatDate,
formatZAR,
getApprovalStatusBadge,
getPriorityBadge,
getRiskScoreColor,
getUserEmail,
} from '../../lib/formatters';
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
interface ProjectExpandedRowProps {
project: Project;
onOpenMilestonesModal: () => void;
onOpenActivitiesModal: () => void;
onOpenIndicatorsModal: () => void;
onOpenRisksModal: () => void;
onOpenEditProject: () => void;
}
export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
project,
onOpenMilestonesModal,
onOpenActivitiesModal,
onOpenIndicatorsModal,
onOpenRisksModal,
onOpenEditProject,
}) => {
const projectId = project.id;
const { data: activities = [] } = useQuery({
queryKey: ['activities', projectId],
queryFn: () => getActivities(projectId),
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones', projectId],
queryFn: () => getMilestones(projectId),
});
const { data: indicators = [] } = useQuery({
queryKey: ['indicators', projectId],
queryFn: () => getProjectIndicators(projectId),
});
const { data: risks = [] } = useQuery({
queryKey: ['risks', projectId],
queryFn: () => getProjectRisks(projectId),
});
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return (
<div className="p-5 sm:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200">
{/* Project Meta Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800">
<div className="space-y-1 max-w-3xl">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
Project #{project.id} Summary
</span>
<span className="text-xs text-slate-400">
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
</div>
<div className="flex items-center gap-2 shrink-0 self-start md:self-center flex-wrap">
<Link
to={`/monitoring?tab=evidence&projectId=${project.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Review Evidence Queue
</Link>
<button
type="button"
onClick={onOpenEditProject}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
Edit Project Scope
</button>
</div>
</div>
{/* Gantt-style Timeline Component */}
<ProjectTimelineGantt
project={project}
milestones={milestones}
activities={activities}
onManageMilestones={onOpenMilestonesModal}
onManageActivities={onOpenActivitiesModal}
/>
{/* 4 Vital Resource Summary Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Milestones */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
<div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Flag className="w-4 h-4 text-blue-400" />
<span>Milestones ({milestones.length})</span>
</div>
<span className="text-[10px] font-mono text-emerald-400 font-bold bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
{verifiedMilestonesCount}/{milestones.length} Verified
</span>
</div>
{milestones.length === 0 ? (
<p className="text-xs text-slate-500 py-3 text-center">No contractual milestones defined.</p>
) : (
<div className="space-y-2">
{milestones.slice(0, 3).map((m) => {
const sBadge = getApprovalStatusBadge(m.status);
return (
<div key={m.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
<div className="flex items-start justify-between gap-1">
<span className="font-semibold text-slate-200 truncate block">{m.name}</span>
<span className={`shrink-0 text-[9px] px-1.5 py-0.2 rounded border ${sBadge.className}`}>
{sBadge.label}
</span>
</div>
<div className="text-[10px] text-slate-400 flex items-center gap-1 font-mono">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</div>
</div>
);
})}
{milestones.length > 3 && (
<p className="text-[10px] text-slate-400 text-center">+{milestones.length - 3} more deliverables</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenMilestonesModal}
className="w-full py-2 px-3 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition flex items-center justify-center gap-1 cursor-pointer"
>
<span>Manage Milestones</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 2: Activities */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
<div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Briefcase className="w-4 h-4 text-cyan-400" />
<span>Activities ({activities.length})</span>
</div>
<span className="text-[10px] font-mono text-cyan-400 font-bold bg-cyan-500/10 px-2 py-0.5 rounded-full border border-cyan-500/30">
{formatZAR(totalActivityBudget)}
</span>
</div>
{activities.length === 0 ? (
<p className="text-xs text-slate-500 py-3 text-center">No work activities recorded.</p>
) : (
<div className="space-y-2">
{activities.slice(0, 3).map((act) => (
<div key={act.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
<div className="flex items-start justify-between gap-1">
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
<span className="font-mono text-cyan-400 font-bold shrink-0">{formatZAR(act.amount)}</span>
</div>
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>{act.category || 'Operations'}</span>
<span>{formatDate(act.performedAt)}</span>
</div>
</div>
))}
{activities.length > 3 && (
<p className="text-[10px] text-slate-400 text-center">+{activities.length - 3} more tasks</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenActivitiesModal}
className="w-full py-2 px-3 text-xs font-semibold text-cyan-400 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition flex items-center justify-center gap-1 cursor-pointer"
>
<span>Manage Activities</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 3: Indicators */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
<div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<Target className="w-4 h-4 text-indigo-400" />
<span>Indicators ({indicators.length})</span>
</div>
<span className="text-[10px] font-mono text-indigo-400 font-bold bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/30">
M&E Targets
</span>
</div>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-3 text-center">No M&E indicators defined.</p>
) : (
<div className="space-y-2">
{indicators.slice(0, 3).map((ind) => {
const target = Number(ind.targetAmount) || 1;
const actual = Number(ind.actualAmount) || 0;
const pct = Math.round((actual / target) * 100);
return (
<div key={ind.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
<div className="flex items-start justify-between gap-1">
<span className="font-semibold text-slate-200 truncate block">{ind.name}</span>
<span className={pct >= 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold'}>
{pct}%
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>Actual: <strong className="text-slate-200 font-mono">{actual}</strong></span>
<span>Target: <strong className="text-slate-200 font-mono">{target}</strong></span>
</div>
</div>
);
})}
{indicators.length > 3 && (
<p className="text-[10px] text-slate-400 text-center">+{indicators.length - 3} more indicators</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenIndicatorsModal}
className="w-full py-2 px-3 text-xs font-semibold text-indigo-400 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition flex items-center justify-center gap-1 cursor-pointer"
>
<span>Manage Indicators</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Card 4: Risks */}
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
<div className="space-y-2.5">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div className="flex items-center gap-2 text-xs font-bold text-white">
<ShieldAlert className="w-4 h-4 text-rose-400" />
<span>Risks ({risks.length})</span>
</div>
<span className="text-[10px] font-mono text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/30">
ISO 31000
</span>
</div>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-3 text-center">No project hazards logged.</p>
) : (
<div className="space-y-2">
{risks.slice(0, 3).map((rk) => {
const score = (rk.likelihood || 1) * (rk.impact || 1);
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
return (
<div key={rk.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
<div className="flex items-start justify-between gap-1">
<span className="font-semibold text-slate-200 truncate block">{rk.name}</span>
<span className={`text-[9px] px-1.5 py-0.2 rounded font-mono ${riskInfo.badge}`}>
{score}/25 {riskInfo.level}
</span>
</div>
<p className="text-[10px] text-slate-400 line-clamp-1">{rk.description}</p>
</div>
);
})}
{risks.length > 3 && (
<p className="text-[10px] text-slate-400 text-center">+{risks.length - 3} more risks</p>
)}
</div>
)}
</div>
<button
type="button"
onClick={onOpenRisksModal}
className="w-full py-2 px-3 text-xs font-semibold text-rose-400 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition flex items-center justify-center gap-1 cursor-pointer"
>
<span>Manage Risks</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,287 @@
import React, { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { createProject, updateProject } from '../../services/api';
import {
Project,
Programme,
IndustrySector,
ProjectType,
CreateProjectRequest,
} from '../../types';
import { INDUSTRY_SECTOR_OPTIONS, PROJECT_TYPE_OPTIONS } from '../../lib/formatters';
const projectSchema = z.object({
name: z.string().min(4, 'Project name must be at least 4 characters'),
description: z.string().min(10, 'Provide a meaningful implementation description (min 10 chars)'),
programmeId: z.coerce.number().min(1, 'Select a parent programme'),
sector: z.coerce.number().min(1),
projectType: z.coerce.number().min(1),
startedAt: z.string().optional().nullable(),
endedAt: z.string().optional().nullable(),
});
type ProjectFormValues = z.infer<typeof projectSchema>;
interface ProjectFormModalProps {
isOpen: boolean;
onClose: () => void;
editingProject: Project | null;
programmes: Programme[];
defaultProgrammeId?: number | string;
}
export const ProjectFormModal: React.FC<ProjectFormModalProps> = ({
isOpen,
onClose,
editingProject,
programmes,
defaultProgrammeId,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const form = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema) as any,
defaultValues: {
name: '',
description: '',
programmeId: defaultProgrammeId ? Number(defaultProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 1),
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
startedAt: '',
endedAt: '',
},
});
useEffect(() => {
if (isOpen) {
if (editingProject) {
form.reset({
name: editingProject.name || '',
description: editingProject.description || '',
programmeId: editingProject.programmeId
? Number(editingProject.programmeId)
: (programmes[0]?.id ? Number(programmes[0].id) : 1),
sector: editingProject.sector || IndustrySector.AgriTech,
projectType: editingProject.projectType || ProjectType.Innovation,
startedAt: editingProject.startedAt ? editingProject.startedAt.slice(0, 10) : '',
endedAt: editingProject.endedAt ? editingProject.endedAt.slice(0, 10) : '',
});
} else {
form.reset({
name: '',
description: '',
programmeId: defaultProgrammeId ? Number(defaultProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 1),
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
startedAt: new Date().toISOString().slice(0, 10),
endedAt: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
});
}
}
}, [isOpen, editingProject, defaultProgrammeId, programmes, form]);
const mutation = useMutation({
mutationFn: async (data: ProjectFormValues) => {
if (editingProject) {
return updateProject({
projectId: editingProject.id,
name: data.name,
description: data.description,
startedAt: data.startedAt ? new Date(data.startedAt).toISOString() : null,
endedAt: data.endedAt ? new Date(data.endedAt).toISOString() : null,
sector: Number(data.sector),
projectType: Number(data.projectType),
});
}
return createProject(data as CreateProjectRequest);
},
onSuccess: (savedProj) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success(
editingProject ? 'Project Updated' : 'Project Registered',
`Project "${savedProj.name}" was successfully saved.`
);
onClose();
form.reset();
},
onError: (err: unknown) => {
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
},
});
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'}
subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'}
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
maxWidth="lg"
>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-4">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
<HelpTooltip
title="Project Title"
content="Clear, unique operational project title corresponding to the legal agreement."
size="sm"
placement="right"
/>
</div>
<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. Drone Precision Seed & Thermal Scouting"
/>
{form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)}
</div>
{!editingProject && (
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Parent Programme *</label>
<HelpTooltip
title="Parent Programme"
content="The national statutory funding vehicle financing this initiative."
size="sm"
placement="right"
/>
</div>
<select
{...form.register('programmeId')}
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"
>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Industry Sector *</label>
<HelpTooltip
title="Industrial Sector"
content="Primary industrial or economic sector for national cluster analytics."
size="sm"
placement="top"
/>
</div>
<select
{...form.register('sector')}
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"
>
{INDUSTRY_SECTOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Type *</label>
<HelpTooltip
title="Project Type"
content="Category of execution: Grant, Commercialisation, Infrastructure, or Research."
size="sm"
placement="top"
/>
</div>
<select
{...form.register('projectType')}
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"
>
{PROJECT_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Start Date</label>
<HelpTooltip
title="Project Start Date"
content="Scheduled or actual project commencement date."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...form.register('startedAt')}
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]"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Target Completion Date</label>
<HelpTooltip
title="Target Completion Date"
content="Projected or contractual conclusion date for statutory reporting."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...form.register('endedAt')}
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]"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Scope & Objectives *</label>
<textarea
rows={3}
{...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-blue-500 text-white bg-[#090d16]"
placeholder="Outline the operational work, methodology, and key milestones..."
/>
{form.formState.errors.description && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.description.message}</p>
)}
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3.5 py-2 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-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 cursor-pointer"
>
{mutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
</button>
</div>
</form>
</Modal>
);
};
@@ -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>
);
};
@@ -0,0 +1,384 @@
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 { Link } from 'react-router-dom';
import {
Flag,
Plus,
Edit2,
Clock,
FileCheck,
CheckCircle2,
AlertCircle,
TrendingUp,
X,
RotateCcw,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import {
getMilestones,
createMilestone,
updateMilestone,
} from '../../services/api';
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
import {
formatDate,
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 [isFormOpen, setIsFormOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
const projectId = project?.id;
const {
data: milestones = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['milestones', projectId],
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema) as any,
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;
const verifiedCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const progressPct = milestones.length > 0 ? Math.round((verifiedCount / milestones.length) * 100) : 0;
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 specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
maxWidth="xl"
>
<div className="space-y-5">
{/* Progress & Quick Stats 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">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Completion Score
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{progressPct}%</span>
<span className="text-xs text-slate-400">({verifiedCount} of {milestones.length} deliverables verified)</span>
</div>
</div>
<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-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 as any))} 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 / Verification Status</label>
<select
{...form.register('status')}
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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</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>
{isLoading ? (
<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-2 max-h-[380px] overflow-y-auto pr-1">
{milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
const isApproved = m.status === ApprovalStatus.Approved;
return (
<div
key={m.id}
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition flex flex-col sm:flex-row sm:items-center 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>
</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>&bull;</span>
<span>ID #{m.id}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center">
<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>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,367 @@
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 {
ShieldAlert,
Plus,
Edit2,
AlertTriangle,
X,
RotateCcw,
CheckCircle2,
ShieldCheck,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import {
getProjectRisks,
createRisk,
updateRisk,
} from '../../services/api';
import { Project, Risk, RiskLikelihood, RiskImpact } from '../../types';
import {
RISK_LIKELIHOOD_OPTIONS,
RISK_IMPACT_OPTIONS,
getRiskScoreColor,
} from '../../lib/formatters';
const riskSchema = z.object({
name: z.string().min(5, 'Risk title required (min 5 chars)'),
description: z.string().min(10, 'Risk description required (min 10 chars)'),
likelihood: z.coerce.number().min(1).max(5),
impact: z.coerce.number().min(1).max(5),
});
type RiskFormValues = z.infer<typeof riskSchema>;
interface ProjectRisksModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
}
export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
isOpen,
onClose,
project,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const projectId = project?.id;
const {
data: risks = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['risks', projectId],
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any,
defaultValues: {
name: '',
description: '',
likelihood: RiskLikelihood.Possible,
impact: RiskImpact.Moderate,
},
});
const openCreateForm = () => {
setEditingRisk(null);
form.reset({
name: '',
description: '',
likelihood: RiskLikelihood.Possible,
impact: RiskImpact.Moderate,
});
setIsFormOpen(true);
};
const openEditForm = (r: Risk) => {
setEditingRisk(r);
form.reset({
name: r.name || '',
description: r.description || '',
likelihood: r.likelihood || RiskLikelihood.Possible,
impact: r.impact || RiskImpact.Moderate,
});
setIsFormOpen(true);
};
const mutation = useMutation({
mutationFn: async (data: RiskFormValues): Promise<void> => {
if (!projectId) return;
if (editingRisk) {
await updateRisk({
riskId: editingRisk.id,
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
} else {
await createRisk({
projectId: projectId,
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risks', projectId] });
queryClient.invalidateQueries({ queryKey: ['risks'] });
success(
editingRisk ? 'Risk Updated' : 'Risk Registered',
editingRisk ? 'Risk register item updated' : 'Risk registered to ISO 31000 risk matrix'
);
setIsFormOpen(false);
setEditingRisk(null);
form.reset();
},
onError: (err: unknown) => {
toastError(editingRisk ? 'Failed to update risk' : 'Failed to register risk', err);
},
});
if (!project) return null;
const highRisksCount = risks.filter((r) => r.likelihood * r.impact >= 12).length;
return (
<Modal
isOpen={isOpen}
onClose={() => {
setIsFormOpen(false);
setEditingRisk(null);
onClose();
}}
title={`Risk Register: ${project.name}`}
subtitle={`Project #${project.id} • ISO 31000 Risk & Mitigation Controls`}
helpText="Assesses project operational, financial, and technical hazards by Likelihood (1-5) and Consequence/Impact (1-5), establishing proactive mitigation commitments."
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">
<ShieldAlert className="w-3.5 h-3.5 text-rose-400" />
Project Risk Profile
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{risks.length}</span>
<span className="text-xs text-slate-400">
({highRisksCount} critical/high exposure items)
</span>
</div>
</div>
<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-rose-600 to-amber-600 hover:from-rose-500 hover:to-amber-500 rounded-xl shadow-md shadow-rose-600/20 transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Risk
</button>
)}
</div>
</div>
{/* Create / Edit Risk Form */}
{isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-rose-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-rose-400 flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5" />
{editingRisk ? `Edit Risk #${editingRisk.id}` : 'Log New Project Hazard & Risk'}
</span>
<button
type="button"
onClick={() => {
setIsFormOpen(false);
setEditingRisk(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 as any))} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard 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-rose-500 text-white bg-[#090d16]"
placeholder="e.g. Critical Supply Chain Delay for Imported Telemetry Chips"
/>
{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">Likelihood (1 - 5) *</label>
<select
{...form.register('likelihood')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 bg-[#090d16] text-white font-medium"
>
{RISK_LIKELIHOOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value} - {opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Consequence / Impact (1 - 5) *</label>
<select
{...form.register('impact')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 bg-[#090d16] text-white font-medium"
>
{RISK_IMPACT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value} - {opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description & Potential Impact *</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-rose-500 text-white bg-[#090d16]"
placeholder="Identify root cause, exposure pathway, and operational consequences..."
/>
{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);
setEditingRisk(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-rose-600 to-amber-600 hover:from-rose-500 hover:to-amber-500 rounded-xl shadow-md shadow-rose-500/20 cursor-pointer"
>
{mutation.isPending ? 'Saving...' : editingRisk ? 'Save Changes' : 'Log Risk'}
</button>
</div>
</form>
</div>
)}
{/* Risks List */}
<div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
<span>Risk Matrix Registry ({risks.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 project risks...
</div>
) : risks.length === 0 ? (
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No project risks logged</p>
<p className="text-[11px] text-slate-500">Log hazards to enforce compliance with ISO 31000 risk controls.</p>
<button
type="button"
onClick={openCreateForm}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-rose-400 hover:underline"
>
<Plus className="w-3.5 h-3.5" /> Log first risk
</button>
</div>
) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
{risks.map((rk) => {
const score = (rk.likelihood || 1) * (rk.impact || 1);
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
return (
<div
key={rk.id}
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{rk.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-md font-mono ${riskInfo.badge}`}>
Score: {score} / 25 ({riskInfo.level})
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">
Likelihood: {rk.likelihood}/5 &bull; Impact: {rk.impact}/5 &bull; Risk ID #{rk.id}
</span>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => openEditForm(rk)}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Risk"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 leading-relaxed">{rk.description}</p>
{rk.mitigationAction && (
<div className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2.5 rounded-lg border border-emerald-500/20">
<strong className="text-emerald-300">Mitigation Control:</strong> {rk.mitigationAction}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,415 @@
import React, { useMemo } from 'react';
import {
Flag,
Calendar,
Clock,
Briefcase,
CheckCircle2,
AlertCircle,
TrendingUp,
Plus,
Coins,
} from 'lucide-react';
import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types';
import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters';
interface ProjectTimelineGanttProps {
project: Project;
milestones: Milestone[];
activities: Activity[];
onManageMilestones: () => void;
onManageActivities: () => void;
}
export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
project,
milestones,
activities,
onManageMilestones,
onManageActivities,
}) => {
// Compute timeline boundaries and items
const timelineData = useMemo(() => {
const dates: number[] = [];
if (project.startedAt) dates.push(new Date(project.startedAt).getTime());
if (project.endedAt) dates.push(new Date(project.endedAt).getTime());
milestones.forEach((m) => {
if (m.dueAt) dates.push(new Date(m.dueAt).getTime());
});
activities.forEach((a) => {
if (a.performedAt) dates.push(new Date(a.performedAt).getTime());
});
const now = Date.now();
dates.push(now);
let startMs: number;
let endMs: number;
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
startMs = Math.min(...dates);
endMs = Math.max(...dates);
// Add a slight buffer (5% on each side) for aesthetic padding
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 24 * 60 * 60 * 1000);
startMs -= buffer;
endMs += buffer;
} else {
// Default: current quarter +/- 3 months
startMs = now - 90 * 24 * 60 * 60 * 1000;
endMs = now + 180 * 24 * 60 * 60 * 1000;
}
const totalDurationMs = endMs - startMs;
// Helper to calculate percentage offset along timeline
const getPercent = (timeMs: number): number => {
const clamped = Math.max(startMs, Math.min(endMs, timeMs));
return ((clamped - startMs) / totalDurationMs) * 100;
};
// Generate monthly ticks for ruler
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = [];
const currDate = new Date(startMs);
currDate.setDate(1);
currDate.setHours(0, 0, 0, 0);
while (currDate.getTime() <= endMs) {
if (currDate.getTime() >= startMs) {
ticks.push({
label: currDate.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }),
percent: getPercent(currDate.getTime()),
isStartOfYear: currDate.getMonth() === 0,
});
}
currDate.setMonth(currDate.getMonth() + 1);
}
// Sort milestones & activities by date
const sortedMilestones = [...milestones].sort(
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
);
const sortedActivities = [...activities].sort(
(a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime()
);
const todayPercent = getPercent(now);
const isTodayInRange = todayPercent >= 0 && todayPercent <= 100;
return {
startMs,
endMs,
totalDurationDays: Math.round(totalDurationMs / (1000 * 60 * 60 * 24)),
getPercent,
ticks,
sortedMilestones,
sortedActivities,
todayPercent,
isTodayInRange,
};
}, [project, milestones, activities]);
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
return (
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner">
{/* Timeline Header & Key Stats */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80">
<div className="flex items-center gap-2.5">
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400">
<Calendar className="w-4 h-4" />
</div>
<div>
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
Implementation Schedule & Timeline
<span className="text-[10px] normal-case font-normal text-slate-400">
({timelineData.totalDurationDays} Days Span)
</span>
</h4>
<p className="text-[11px] text-slate-400">
Chronological alignment of contractual deliverables and operational activities.
</p>
</div>
</div>
{/* Quick Legend & Actions */}
<div className="flex items-center gap-3 text-[11px] flex-wrap">
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" />
Verified Deliverable
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" />
Pending / Review
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
Work Activity
</span>
</div>
</div>
</div>
{/* Main Gantt Timeline Track Container */}
<div className="space-y-4 pt-1">
{/* Month Scale / Ruler */}
<div className="relative h-6 w-full border-b border-slate-800 text-[10px] font-mono text-slate-500 select-none">
{timelineData.ticks.map((tick, idx) => (
<div
key={idx}
className="absolute transform -translate-x-1/2 flex flex-col items-center"
style={{ left: `${tick.percent}%` }}
>
<span className={tick.isStartOfYear ? 'text-blue-400 font-bold' : 'text-slate-400'}>
{tick.label}
</span>
<span className="h-1.5 w-px bg-slate-700 mt-0.5" />
</div>
))}
</div>
{/* Timeline Visualization Surface */}
<div className="relative min-h-[220px] bg-[#090d16] rounded-xl border border-slate-800/90 p-4 overflow-x-auto">
{/* Background vertical grid lines */}
<div className="absolute inset-0 pointer-events-none">
{timelineData.ticks.map((tick, idx) => (
<div
key={idx}
className="absolute top-0 bottom-0 w-px bg-slate-800/40"
style={{ left: `${tick.percent}%` }}
/>
))}
{/* Today marker */}
{timelineData.isTodayInRange && (
<div
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
style={{ left: `${timelineData.todayPercent}%` }}
>
<span className="px-1.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/40 rounded-full whitespace-nowrap -translate-y-2 shadow-sm">
Today
</span>
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30" />
</div>
)}
</div>
{/* Layer 1: Milestones Track */}
<div className="relative z-10 mb-6 space-y-2">
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
<span className="flex items-center gap-1.5 text-blue-400">
<Flag className="w-3.5 h-3.5" />
Contractual Deliverables & Milestones ({milestones.length})
</span>
<button
type="button"
onClick={onManageMilestones}
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
>
<Plus className="w-3 h-3" /> Add / Manage Milestones
</button>
</div>
{timelineData.sortedMilestones.length === 0 ? (
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
No contractual milestones defined yet. Click &quot;Add / Manage Milestones&quot; to establish deliverables.
</div>
) : (
<div className="relative h-20 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
{/* Milestone connecting base line */}
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" />
{/* Milestone Pins */}
{timelineData.sortedMilestones.map((m, idx) => {
const mTime = new Date(m.dueAt || 0).getTime();
const pos = timelineData.getPercent(mTime);
const isApproved = m.status === ApprovalStatus.Approved;
const isPending = m.status === ApprovalStatus.Pending;
const priBadge = getPriorityBadge(m.priority);
return (
<div
key={m.id || idx}
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
style={{ left: `${Math.min(96, Math.max(4, pos))}%` }}
onClick={onManageMilestones}
>
{/* Flag Marker */}
<div
className={`h-8 w-8 rounded-full flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
isApproved
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300 shadow-emerald-500/20'
: isPending
? 'bg-amber-500/20 border-amber-400 text-amber-300 shadow-amber-500/20'
: 'bg-blue-500/20 border-blue-400 text-blue-300 shadow-blue-500/20'
}`}
>
<Flag className="w-3.5 h-3.5 fill-current" />
</div>
{/* Brief Pin Label */}
<div className="mt-1 text-center whitespace-nowrap">
<span className="text-[10px] font-bold text-slate-300 block max-w-[110px] truncate">
{m.name}
</span>
<span className="text-[9px] text-slate-500 font-mono">
{formatDate(m.dueAt)}
</span>
</div>
{/* Tooltip on Hover */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:flex flex-col w-56 p-2.5 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl text-left z-30 pointer-events-none">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-[10px] font-mono text-blue-400">Milestone #{m.id}</span>
<span className={`text-[9px] px-1.5 py-0.2 rounded border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
<p className="text-xs font-bold text-white leading-tight mb-1">{m.name}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-800">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
{formatDate(m.dueAt)}
</span>
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}>
{isApproved ? 'Verified' : 'Pending'}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Layer 2: Activities Track */}
<div className="relative z-10 space-y-2">
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
<span className="flex items-center gap-1.5 text-cyan-400">
<Briefcase className="w-3.5 h-3.5" />
Work Breakdown Activities ({activities.length})
</span>
<button
type="button"
onClick={onManageActivities}
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
>
<Plus className="w-3 h-3" /> Add / Manage Activities
</button>
</div>
{timelineData.sortedActivities.length === 0 ? (
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
No activities recorded in work breakdown structure.
</div>
) : (
<div className="space-y-1.5">
{timelineData.sortedActivities.slice(0, 4).map((act, idx) => {
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
const pos = timelineData.getPercent(aTime);
return (
<div
key={act.id || idx}
className="group relative flex items-center p-2 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
onClick={onManageActivities}
>
<div className="w-48 shrink-0 pr-3 border-r border-slate-800 truncate">
<span className="text-xs font-semibold text-white block truncate">{act.title}</span>
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
</div>
<div className="flex-1 relative h-6 mx-3 flex items-center">
<div className="w-full h-1 bg-slate-800 rounded-full" />
<div
className="absolute h-4 rounded-md bg-cyan-500/20 border border-cyan-400/60 px-2 flex items-center text-[9px] font-bold text-cyan-300 shadow-sm shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
style={{ left: `${Math.min(94, Math.max(6, pos))}%` }}
>
<Coins className="w-2.5 h-2.5 mr-1 text-cyan-400" />
{formatZAR(act.amount)}
</div>
</div>
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400">
{formatDate(act.performedAt)}
</div>
</div>
);
})}
{activities.length > 4 && (
<div className="text-center pt-1">
<button
type="button"
onClick={onManageActivities}
className="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
>
+ View all {activities.length} work activities &rarr;
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Bottom Summary KPI Bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
<span className="text-sm font-bold text-white mt-0.5 block">
{timelineData.totalDurationDays} Days
</span>
<span className="text-[10px] text-slate-400">
{formatDate(project.startedAt)} &rarr; {formatDate(project.endedAt)}
</span>
</div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Milestone Progress</span>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full mt-1.5 overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all"
style={{ width: `${milestoneProgress}%` }}
/>
</div>
</div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Work Breakdown Cost</span>
<span className="text-sm font-bold text-cyan-400 mt-0.5 block font-mono">
{formatZAR(totalActivityBudget)}
</span>
<span className="text-[10px] text-slate-400">{activities.length} total tasks</span>
</div>
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-500 block">Governance State</span>
<div className="mt-1">
{(() => {
const b = getApprovalStatusBadge(project.status);
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${b.className}`}>
{b.label}
</span>
);
})()}
</div>
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff