288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|