Refactored projects and milestones page #119
@@ -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>•</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>•</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 • Impact: {rk.impact}/5 • 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 "Add / Manage Milestones" 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 →
|
||||
</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)} → {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
Reference in New Issue
Block a user