385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|