Files
pfm/PostFundManagement.ConsoleUI/src/components/projects/ProjectFormModal.tsx
T

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>
);
};