Refactored projects and milestones page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user