1336 lines
60 KiB
TypeScript
1336 lines
60 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
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 {
|
||
FolderKanban,
|
||
Plus,
|
||
CheckCircle2,
|
||
Calendar,
|
||
Layers,
|
||
ChevronRight,
|
||
Clock,
|
||
Briefcase,
|
||
AlertCircle,
|
||
Flag,
|
||
FileCheck,
|
||
TrendingUp,
|
||
X,
|
||
Edit2,
|
||
} from 'lucide-react';
|
||
import {
|
||
getProjects,
|
||
createProject,
|
||
updateProject,
|
||
updateProjectStatus,
|
||
getActivities,
|
||
createActivity,
|
||
updateActivity,
|
||
getMilestones,
|
||
createMilestone,
|
||
updateMilestone,
|
||
getAllProgrammes,
|
||
getProgrammeProjects,
|
||
getActivity,
|
||
getMilestone,
|
||
getProjectIndicators,
|
||
getProjectRisks,
|
||
} from '../services/api';
|
||
import { Modal } from '../components/ui/Modal';
|
||
import { useToast } from '../components/ui/Toast';
|
||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||
import {
|
||
Project,
|
||
Activity,
|
||
Milestone,
|
||
IndustrySector,
|
||
ProjectType,
|
||
ApprovalStatus,
|
||
Priority,
|
||
CreateProjectRequest,
|
||
CreateActivityRequest,
|
||
CreateMilestoneRequest,
|
||
UpdateActivityRequest,
|
||
UpdateMilestoneRequest,
|
||
} from '../types';
|
||
import {
|
||
getIndustrySectorLabel,
|
||
getProjectTypeLabel,
|
||
getApprovalStatusBadge,
|
||
getPriorityBadge,
|
||
formatDate,
|
||
formatZAR,
|
||
getUserEmail,
|
||
INDUSTRY_SECTOR_OPTIONS,
|
||
PROJECT_TYPE_OPTIONS,
|
||
PRIORITY_OPTIONS,
|
||
APPROVAL_STATUS_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'),
|
||
programmeId: z.coerce.number().min(1, 'Select a programme'),
|
||
sector: z.coerce.number().min(1),
|
||
projectType: z.coerce.number().min(1),
|
||
startedAt: z.string().optional().nullable(),
|
||
endedAt: z.string().optional().nullable(),
|
||
});
|
||
|
||
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(),
|
||
});
|
||
|
||
const milestoneSchema = z.object({
|
||
name: z.string().min(4, 'Milestone deliverable required'),
|
||
dueAt: z.string().min(4, 'Due date is mandatory'),
|
||
priority: z.coerce.number().min(1).max(4),
|
||
status: z.coerce.number().optional(),
|
||
});
|
||
|
||
type ProjectFormValues = z.infer<typeof projectSchema>;
|
||
type ActivityFormValues = z.infer<typeof activitySchema>;
|
||
type MilestoneFormValues = z.infer<typeof milestoneSchema>;
|
||
|
||
export const ProjectsPage: React.FC = () => {
|
||
const queryClient = useQueryClient();
|
||
const { success, error: toastError } = useToast();
|
||
|
||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
|
||
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
|
||
|
||
// Modals & Editing State
|
||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||
|
||
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
|
||
const [editingActivity, setEditingActivity] = useState<Activity | null>(null);
|
||
|
||
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
|
||
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
||
|
||
// Queries
|
||
const { data: programmes = [] } = useQuery({
|
||
queryKey: ['programmes'],
|
||
queryFn: () => getAllProgrammes(),
|
||
});
|
||
|
||
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||
queryKey: ['projects', selectedProgrammeFilter],
|
||
queryFn: () =>
|
||
selectedProgrammeFilter !== 'all'
|
||
? getProgrammeProjects(selectedProgrammeFilter)
|
||
: getProjects(),
|
||
});
|
||
|
||
// Details Queries (for currently selected project)
|
||
const activeProjectId = selectedProject ? selectedProject.id : null;
|
||
|
||
const { data: activities = [] } = useQuery({
|
||
queryKey: ['activities', activeProjectId],
|
||
queryFn: () => (activeProjectId ? getActivities(activeProjectId) : Promise.resolve([])),
|
||
enabled: !!activeProjectId,
|
||
});
|
||
|
||
const { data: milestones = [] } = useQuery({
|
||
queryKey: ['milestones', activeProjectId],
|
||
queryFn: () => (activeProjectId ? getMilestones(activeProjectId) : Promise.resolve([])),
|
||
enabled: !!activeProjectId,
|
||
});
|
||
|
||
const { data: indicators = [] } = useQuery({
|
||
queryKey: ['indicators', activeProjectId],
|
||
queryFn: () => (activeProjectId ? getProjectIndicators(activeProjectId) : Promise.resolve([])),
|
||
enabled: !!activeProjectId,
|
||
});
|
||
|
||
const { data: risks = [] } = useQuery({
|
||
queryKey: ['risks', activeProjectId],
|
||
queryFn: () => (activeProjectId ? getProjectRisks(activeProjectId) : Promise.resolve([])),
|
||
enabled: !!activeProjectId,
|
||
});
|
||
|
||
// Forms
|
||
const projectForm = useForm<ProjectFormValues>({
|
||
resolver: zodResolver(projectSchema) as any,
|
||
defaultValues: {
|
||
name: '',
|
||
description: '',
|
||
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
|
||
sector: IndustrySector.AgriTech,
|
||
projectType: ProjectType.Innovation,
|
||
startedAt: '',
|
||
endedAt: '',
|
||
},
|
||
});
|
||
|
||
const activityForm = useForm<ActivityFormValues>({
|
||
resolver: zodResolver(activitySchema) as any,
|
||
defaultValues: {
|
||
title: '',
|
||
description: '',
|
||
category: '',
|
||
amount: 0,
|
||
milestoneId: '',
|
||
performedAt: '',
|
||
},
|
||
});
|
||
|
||
const milestoneForm = useForm<MilestoneFormValues>({
|
||
resolver: zodResolver(milestoneSchema) as any,
|
||
defaultValues: {
|
||
name: '',
|
||
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||
priority: Priority.High,
|
||
status: ApprovalStatus.Pending,
|
||
},
|
||
});
|
||
|
||
// Open Handlers
|
||
const openCreateProject = () => {
|
||
setEditingProject(null);
|
||
projectForm.reset({
|
||
name: '',
|
||
description: '',
|
||
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
|
||
sector: IndustrySector.AgriTech,
|
||
projectType: ProjectType.Innovation,
|
||
startedAt: '',
|
||
endedAt: '',
|
||
});
|
||
setIsProjectModalOpen(true);
|
||
};
|
||
|
||
const openEditProject = (p: Project) => {
|
||
setEditingProject(p);
|
||
projectForm.reset({
|
||
name: p.name || '',
|
||
description: p.description || '',
|
||
programmeId: p.programmeId ? Number(p.programmeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
|
||
sector: p.sector || IndustrySector.AgriTech,
|
||
projectType: p.projectType || ProjectType.Innovation,
|
||
startedAt: p.startedAt ? p.startedAt.slice(0, 10) : '',
|
||
endedAt: p.endedAt ? p.endedAt.slice(0, 10) : '',
|
||
});
|
||
setIsProjectModalOpen(true);
|
||
};
|
||
|
||
const openCreateActivity = () => {
|
||
setEditingActivity(null);
|
||
activityForm.reset({
|
||
title: '',
|
||
description: '',
|
||
category: '',
|
||
amount: 0,
|
||
milestoneId: '',
|
||
performedAt: new Date().toISOString().slice(0, 10),
|
||
});
|
||
setIsActivityModalOpen(true);
|
||
};
|
||
|
||
const openEditActivity = (a: Activity) => {
|
||
setEditingActivity(a);
|
||
activityForm.reset({
|
||
title: a.title || '',
|
||
description: a.description || '',
|
||
category: a.category || '',
|
||
amount: a.amount || 0,
|
||
milestoneId: a.milestoneId ? String(a.milestoneId) : '',
|
||
performedAt: a.performedAt ? a.performedAt.slice(0, 10) : '',
|
||
});
|
||
setIsActivityModalOpen(true);
|
||
};
|
||
|
||
const openCreateMilestone = () => {
|
||
setEditingMilestone(null);
|
||
milestoneForm.reset({
|
||
name: '',
|
||
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||
priority: Priority.High,
|
||
status: ApprovalStatus.Pending,
|
||
});
|
||
setIsMilestoneModalOpen(true);
|
||
};
|
||
|
||
const openEditMilestone = (m: Milestone) => {
|
||
setEditingMilestone(m);
|
||
milestoneForm.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,
|
||
});
|
||
setIsMilestoneModalOpen(true);
|
||
};
|
||
|
||
// Mutations
|
||
const projectMutation = useMutation({
|
||
mutationFn: (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}" saved successfully`
|
||
);
|
||
setIsProjectModalOpen(false);
|
||
setEditingProject(null);
|
||
projectForm.reset();
|
||
setSelectedProject(savedProj);
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
|
||
},
|
||
});
|
||
|
||
const statusMutation = useMutation({
|
||
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
||
updateProjectStatus({ projectId, status }),
|
||
onSuccess: (_, variables) => {
|
||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||
if (selectedProject) {
|
||
setSelectedProject({ ...selectedProject, status: variables.status });
|
||
}
|
||
success('Status updated', 'Project governance status changed successfully');
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Failed to update status', err);
|
||
},
|
||
});
|
||
|
||
const activityMutation = useMutation({
|
||
mutationFn: (data: ActivityFormValues) => {
|
||
const parsedMilestoneId =
|
||
data.milestoneId && String(data.milestoneId).trim() !== ''
|
||
? Number(data.milestoneId)
|
||
: null;
|
||
const performedAtIso = data.performedAt ? new Date(data.performedAt).toISOString() : null;
|
||
|
||
if (editingActivity) {
|
||
return updateActivity({
|
||
activityId: editingActivity.id,
|
||
projectId: activeProjectId!,
|
||
title: data.title,
|
||
description: data.description,
|
||
category: data.category,
|
||
amount: Number(data.amount),
|
||
milestoneId: parsedMilestoneId,
|
||
performedAt: performedAtIso,
|
||
});
|
||
}
|
||
return createActivity({
|
||
projectId: activeProjectId!,
|
||
title: data.title,
|
||
description: data.description,
|
||
category: data.category,
|
||
amount: Number(data.amount),
|
||
milestoneId: parsedMilestoneId,
|
||
performedAt: performedAtIso,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
|
||
success(
|
||
editingActivity ? 'Activity updated' : 'Activity added',
|
||
editingActivity
|
||
? 'Activity updated in project work breakdown'
|
||
: 'Activity registered to project work breakdown'
|
||
);
|
||
setIsActivityModalOpen(false);
|
||
setEditingActivity(null);
|
||
activityForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
|
||
},
|
||
});
|
||
|
||
const milestoneMutation = useMutation({
|
||
mutationFn: async (data: MilestoneFormValues): Promise<void> => {
|
||
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: activeProjectId!,
|
||
name: data.name,
|
||
dueAt: data.dueAt,
|
||
priority: Number(data.priority),
|
||
});
|
||
}
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
|
||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||
success(
|
||
editingMilestone ? 'Milestone updated' : 'Milestone created',
|
||
editingMilestone
|
||
? 'Contractual deliverable updated successfully'
|
||
: 'Contractual deliverable added to implementation schedule'
|
||
);
|
||
setIsMilestoneModalOpen(false);
|
||
setEditingMilestone(null);
|
||
milestoneForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
|
||
},
|
||
});
|
||
|
||
// Calculate project progress percentage based on completed milestones
|
||
const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||
const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0;
|
||
|
||
return (
|
||
<div className="space-y-6 max-w-7xl mx-auto">
|
||
{/* Page Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
|
||
Project & Milestone Management
|
||
</h2>
|
||
<HelpTooltip
|
||
title="Work Breakdown Structure (WBS)"
|
||
content="Manage operational activities, verified contractual milestone deliverables, risk registers, and M&E performance indicator targets."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||
Work Breakdown
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-slate-400 mt-1">
|
||
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={openCreateProject}
|
||
className="inline-flex items-center gap-1.5 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 transition cursor-pointer self-start sm:self-auto"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Create Project
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||
{/* Left Column: Project Directory List */}
|
||
<div className="lg:col-span-5 space-y-3">
|
||
<div className="flex items-center justify-between gap-2 px-1">
|
||
<div className="flex items-center gap-1.5">
|
||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||
Projects Directory ({projects.length})
|
||
</h3>
|
||
<HelpTooltip
|
||
title="Project Registry"
|
||
content="Select any funded project to inspect its work breakdown structure, contractual deliverables, progress score, and M&E indicators."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<select
|
||
value={selectedProgrammeFilter}
|
||
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
|
||
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[190px] [&>option]:bg-slate-900 [&>option]:text-white"
|
||
>
|
||
<option value="all">All Programmes</option>
|
||
{programmes.map((pr) => (
|
||
<option key={pr.id} value={pr.id}>
|
||
{pr.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{loadingProjects ? (
|
||
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
|
||
Loading projects...
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{projects.map((p) => {
|
||
const isSelected = selectedProject?.id === p.id;
|
||
const statusBadge = getApprovalStatusBadge(p.status);
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
onClick={() => setSelectedProject(p)}
|
||
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${
|
||
isSelected
|
||
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
|
||
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<span className="font-bold text-sm text-white leading-snug">{p.name}</span>
|
||
<span className={`shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||
{statusBadge.label.split('/')[0]}
|
||
</span>
|
||
</div>
|
||
|
||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">{p.description}</p>
|
||
|
||
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-slate-800/80 text-[11px] text-slate-400">
|
||
<span className="bg-slate-900/80 text-slate-300 border border-slate-700/80 px-2 py-0.5 rounded-md font-medium">
|
||
{getIndustrySectorLabel(p.sector)}
|
||
</span>
|
||
<span>•</span>
|
||
<span className="text-slate-300">{getProjectTypeLabel(p.projectType)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Column: Detailed Project Workspace */}
|
||
<div className="lg:col-span-7">
|
||
{selectedProject ? (
|
||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-xl backdrop-blur-md overflow-hidden sticky top-6">
|
||
{/* Project Header */}
|
||
<div className="p-6 border-b border-slate-800/80 bg-[#0d121f]/90">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<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 #{selectedProject.id}
|
||
</span>
|
||
<h3 className="text-lg font-bold text-white mt-1.5 leading-snug">{selectedProject.name}</h3>
|
||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{selectedProject.description}</p>
|
||
<div className="flex flex-wrap items-center gap-3 mt-2.5 text-[11px] text-slate-400 font-medium">
|
||
<span>
|
||
Created by: <strong className="text-slate-200 font-normal">{getUserEmail(selectedProject.createdBy)}</strong>
|
||
</span>
|
||
<span>•</span>
|
||
<span>
|
||
Sector: <strong className="text-slate-200 font-normal">{getIndustrySectorLabel(selectedProject.sector)}</strong>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions & Status Change Selector */}
|
||
<div className="shrink-0 text-right space-y-2">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Link
|
||
to={`/monitoring?tab=evidence&projectId=${selectedProject.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/15 hover:bg-blue-500/25 border border-blue-500/30 rounded-xl transition cursor-pointer"
|
||
title="Review submitted evidence for this project"
|
||
>
|
||
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||
Review Evidence
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={() => openEditProject(selectedProject)}
|
||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||
title="Edit project details"
|
||
>
|
||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||
Edit Project
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
|
||
<HelpTooltip
|
||
title="Governance Approval Status"
|
||
content="Current statutory clearance stage of the project: Draft, In Review, Approved, or Active."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
<select
|
||
value={selectedProject.status}
|
||
onChange={(e) =>
|
||
statusMutation.mutate({
|
||
projectId: selectedProject.id,
|
||
status: Number(e.target.value) as ApprovalStatus,
|
||
})
|
||
}
|
||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||
>
|
||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress bar calculated from approved milestones */}
|
||
<div className="mt-5 pt-4 border-t border-slate-800/80">
|
||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||
<span className="font-semibold text-slate-300 flex items-center gap-1.5">
|
||
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
|
||
Calculated Progress (Milestone-based)
|
||
<HelpTooltip
|
||
title="Calculated Progress Formula"
|
||
content="Computed dynamically as (Verified & Approved Milestones / Total Milestones) × 100%."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</span>
|
||
<span className="font-bold text-blue-400">{milestoneProgress}%</span>
|
||
</div>
|
||
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 rounded-full"
|
||
style={{ width: `${milestoneProgress}%` }}
|
||
></div>
|
||
</div>
|
||
<div className="flex justify-between text-[10px] text-slate-500 mt-1">
|
||
<span>{completedMilestones} of {milestones.length} milestones verified</span>
|
||
<span>Target: 100% completion</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sub-tabs */}
|
||
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
|
||
<button
|
||
onClick={() => setDetailTab('activities')}
|
||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||
detailTab === 'activities'
|
||
? 'border-blue-500 text-blue-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<Briefcase className="w-3.5 h-3.5" />
|
||
Work Activities ({activities.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setDetailTab('milestones')}
|
||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||
detailTab === 'milestones'
|
||
? 'border-blue-500 text-blue-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<Flag className="w-3.5 h-3.5" />
|
||
Milestones ({milestones.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setDetailTab('indicators')}
|
||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||
detailTab === 'indicators'
|
||
? 'border-blue-500 text-blue-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<FileCheck className="w-3.5 h-3.5" />
|
||
Indicators ({indicators.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setDetailTab('risks')}
|
||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||
detailTab === 'risks'
|
||
? 'border-blue-500 text-blue-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<AlertCircle className="w-3.5 h-3.5" />
|
||
Risks ({risks.length})
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<div className="p-5 max-h-[500px] overflow-y-auto">
|
||
{/* Activities Sub-tab */}
|
||
{detailTab === 'activities' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
|
||
<HelpTooltip
|
||
title="Work Breakdown Structure"
|
||
content="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={openCreateActivity}
|
||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Activity
|
||
</button>
|
||
</div>
|
||
|
||
{activities.length === 0 ? (
|
||
<p className="text-xs text-slate-500 py-4 text-center">No activities recorded yet.</p>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{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/80 bg-[#0d121f]/90">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="space-y-0.5">
|
||
<span className="font-semibold text-xs text-white block">{a.title}</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>
|
||
) : (
|
||
<span className="text-[10px] text-slate-500 italic">Independent Activity</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
|
||
<Link
|
||
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&activityId=${a.id}`}
|
||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 px-2 py-1 rounded-lg transition"
|
||
title="View & review evidence for this activity"
|
||
>
|
||
<FileCheck className="w-3 h-3 text-cyan-400" />
|
||
Evidence
|
||
</Link>
|
||
<button
|
||
onClick={() => openEditActivity(a)}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
||
title="Edit activity"
|
||
>
|
||
<Edit2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-slate-400 mt-1.5 leading-relaxed">{a.description}</p>
|
||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
|
||
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
|
||
<span>Date: {formatDate(a.performedAt)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Milestones Sub-tab */}
|
||
{detailTab === 'milestones' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
|
||
<HelpTooltip
|
||
title="Contractual Milestones"
|
||
content="Statutory deliverables tied to legal grant agreements. Verified completion triggers tranche disbursement releases."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={openCreateMilestone}
|
||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Milestone
|
||
</button>
|
||
</div>
|
||
|
||
{milestones.length === 0 ? (
|
||
<p className="text-xs text-slate-500 py-4 text-center">No milestones scheduled.</p>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{milestones.map((m) => {
|
||
const statusBadge = getApprovalStatusBadge(m.status);
|
||
const priBadge = getPriorityBadge(m.priority);
|
||
return (
|
||
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<span className="font-semibold text-xs text-white block">{m.name}</span>
|
||
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||
{statusBadge.label}
|
||
</span>
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
||
{priBadge.label}
|
||
</span>
|
||
</div>
|
||
<Link
|
||
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
|
||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
|
||
title="View & review evidence for this milestone"
|
||
>
|
||
<FileCheck className="w-3 h-3 text-blue-400" />
|
||
Evidence
|
||
</Link>
|
||
<button
|
||
onClick={() => openEditMilestone(m)}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
||
title="Edit contractual milestone"
|
||
>
|
||
<Edit2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
|
||
<span className="flex items-center gap-1.5 text-slate-400">
|
||
<Clock className="w-3.5 h-3.5 text-slate-500" />
|
||
Due: {formatDate(m.dueAt)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Indicators Sub-tab */}
|
||
{detailTab === 'indicators' && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
|
||
<HelpTooltip
|
||
title="M&E Indicator Tracking"
|
||
content="Monitors quantitative performance targets (e.g. jobs created, patents filed, rural SMMEs trained) against baseline values."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
{indicators.length === 0 ? (
|
||
<p className="text-xs text-slate-500 py-4 text-center">No indicators attached.</p>
|
||
) : (
|
||
indicators.map((ind) => {
|
||
const pct = ind.targetAmount > 0 ? Math.round((ind.actualAmount / ind.targetAmount) * 100) : 0;
|
||
return (
|
||
<div key={ind.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1.5">
|
||
<div className="flex justify-between text-xs font-semibold">
|
||
<span className="text-white">{ind.name}</span>
|
||
<span className={pct >= 90 ? 'text-emerald-400' : 'text-amber-400'}>{pct}%</span>
|
||
</div>
|
||
<div className="flex justify-between text-[11px] text-slate-400">
|
||
<span>Baseline: {ind.baselineAmount}</span>
|
||
<span>Target: {ind.targetAmount}</span>
|
||
<span className="font-bold text-slate-200">Actual: {ind.actualAmount}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Risks Sub-tab */}
|
||
{detailTab === 'risks' && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
|
||
<HelpTooltip
|
||
title="ISO 31000 Risk Register"
|
||
content="Assesses specific operational risks by Likelihood (1-5) and Consequence/Impact (1-5), specifying mitigation commitments."
|
||
size="sm"
|
||
placement="bottom"
|
||
/>
|
||
</div>
|
||
{risks.length === 0 ? (
|
||
<p className="text-xs text-slate-500 py-4 text-center">No project risks logged.</p>
|
||
) : (
|
||
risks.map((rk) => (
|
||
<div key={rk.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1">
|
||
<div className="flex justify-between text-xs font-semibold text-white">
|
||
<span>{rk.name}</span>
|
||
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-mono">
|
||
Score: {rk.likelihood * rk.impact} / 25
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-slate-400">{rk.description}</p>
|
||
{rk.mitigationAction && (
|
||
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2 rounded-lg mt-1.5 border border-emerald-500/20">
|
||
<strong className="text-emerald-300">Mitigation:</strong> {rk.mitigationAction}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bg-[#0f1523]/80 rounded-2xl border border-dashed border-slate-800 p-12 text-center text-slate-400 flex flex-col items-center justify-center">
|
||
<FolderKanban className="w-10 h-10 text-slate-600 mb-3" />
|
||
<p className="text-sm font-semibold text-slate-200">Select a project to inspect implementation</p>
|
||
<p className="text-xs text-slate-500 mt-1">View activities, milestones, indicators, and risk controls.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal: Create or Edit Project */}
|
||
<Modal
|
||
isOpen={isProjectModalOpen}
|
||
onClose={() => {
|
||
setIsProjectModalOpen(false);
|
||
setEditingProject(null);
|
||
}}
|
||
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."
|
||
>
|
||
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.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"
|
||
{...projectForm.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"
|
||
/>
|
||
{projectForm.formState.errors.name && (
|
||
<p className="text-[10px] text-rose-500 mt-1">{projectForm.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
|
||
{...projectForm.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-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
|
||
{...projectForm.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
|
||
{...projectForm.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-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"
|
||
{...projectForm.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"
|
||
{...projectForm.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}
|
||
{...projectForm.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..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsProjectModalOpen(false);
|
||
setEditingProject(null);
|
||
}}
|
||
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={projectMutation.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"
|
||
>
|
||
{projectMutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Add or Edit Activity */}
|
||
<Modal
|
||
isOpen={isActivityModalOpen}
|
||
onClose={() => {
|
||
setIsActivityModalOpen(false);
|
||
setEditingActivity(null);
|
||
}}
|
||
title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'}
|
||
subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`}
|
||
helpText="Define operational tasks and assign them to contractual milestones. The allocated budget will be tracked against expenditure categories."
|
||
>
|
||
<form
|
||
onSubmit={activityForm.handleSubmit((data) => activityMutation.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">Activity Title *</label>
|
||
<HelpTooltip
|
||
title="Activity Title"
|
||
content="Concise description of the specific work task to be undertaken by the project team."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
{...activityForm.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-blue-500 text-white bg-[#090d16]"
|
||
placeholder="e.g. Cleanroom Sensor Calibration"
|
||
/>
|
||
</div>
|
||
|
||
{/* Associated Milestone Dropdown */}
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="block text-xs font-semibold text-slate-300">
|
||
Associated Milestone (Deliverable Link)
|
||
</label>
|
||
<HelpTooltip
|
||
title="Milestone Linkage"
|
||
content="Optionally bind this activity as a work package that contributes to a contractual milestone deliverable."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<select
|
||
{...activityForm.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-blue-500 bg-[#090d16] text-white font-medium"
|
||
>
|
||
<option value="">-- No Milestone / Standalone Activity --</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-2 gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="block text-xs font-semibold text-slate-300">Budget Allocation (ZAR) *</label>
|
||
<HelpTooltip
|
||
title="Activity Budget"
|
||
content="Estimated expenditure or cost required to complete this activity."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
{...activityForm.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-blue-500 font-mono 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">Cost Category *</label>
|
||
<HelpTooltip
|
||
title="Cost Category"
|
||
content="Budget line category (e.g. Capital Equipment, Personnel, Field Trials, Operational)."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
{...activityForm.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-blue-500 text-white bg-[#090d16]"
|
||
placeholder="Hardware / Field Testing"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="block text-xs font-semibold text-slate-300">Execution / Performed Date</label>
|
||
<HelpTooltip
|
||
title="Activity Execution Date"
|
||
content="Scheduled or actual date on which the activity was performed."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="date"
|
||
{...activityForm.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-blue-500 text-white bg-[#090d16]"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
|
||
<textarea
|
||
rows={2}
|
||
{...activityForm.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]"
|
||
/>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsActivityModalOpen(false);
|
||
setEditingActivity(null);
|
||
}}
|
||
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={activityMutation.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"
|
||
>
|
||
{activityMutation.isPending ? 'Saving...' : editingActivity ? 'Update Activity' : 'Add Activity'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Add or Edit Milestone */}
|
||
<Modal
|
||
isOpen={isMilestoneModalOpen}
|
||
onClose={() => {
|
||
setIsMilestoneModalOpen(false);
|
||
setEditingMilestone(null);
|
||
}}
|
||
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
|
||
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
|
||
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."
|
||
>
|
||
<form
|
||
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.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">Milestone Deliverable Name *</label>
|
||
<HelpTooltip
|
||
title="Deliverable Title"
|
||
content="Title of the agreed milestone (e.g. Inception Report, Working Prototype, Pilot Demonstration)."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
{...milestoneForm.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. Milestone 4: Prototype Pilot Validation Report"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid 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">Contractual Due Date *</label>
|
||
<HelpTooltip
|
||
title="Due Date"
|
||
content="Contractual cutoff deadline by which evidence must be submitted for review."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<input
|
||
type="date"
|
||
{...milestoneForm.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]"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="block text-xs font-semibold text-slate-300">Priority Level *</label>
|
||
<HelpTooltip
|
||
title="Milestone Priority"
|
||
content="Criticality of the milestone: Low, Medium, High, or Critical path."
|
||
size="sm"
|
||
placement="top"
|
||
/>
|
||
</div>
|
||
<select
|
||
{...milestoneForm.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>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<label className="block text-xs font-semibold text-slate-300">Approval / Verification Status</label>
|
||
<HelpTooltip
|
||
title="Verification Status"
|
||
content="Only Approved/Verified milestones contribute to the calculated progress percentage."
|
||
size="sm"
|
||
placement="right"
|
||
/>
|
||
</div>
|
||
<select
|
||
{...milestoneForm.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-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsMilestoneModalOpen(false);
|
||
setEditingMilestone(null);
|
||
}}
|
||
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={milestoneMutation.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"
|
||
>
|
||
{milestoneMutation.isPending ? 'Saving...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|