Implemented session and account lockout management
Enabled account sync control and session expiration management Applied some fixes to the govername page and project page
This commit is contained in:
@@ -17,15 +17,19 @@ import {
|
||||
FileCheck,
|
||||
TrendingUp,
|
||||
X,
|
||||
Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
updateProjectStatus,
|
||||
getActivities,
|
||||
createActivity,
|
||||
updateActivity,
|
||||
getMilestones,
|
||||
createMilestone,
|
||||
updateMilestone,
|
||||
getAllProgrammes,
|
||||
getProgrammeProjects,
|
||||
getActivity,
|
||||
@@ -37,6 +41,8 @@ import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import {
|
||||
Project,
|
||||
Activity,
|
||||
Milestone,
|
||||
IndustrySector,
|
||||
ProjectType,
|
||||
ApprovalStatus,
|
||||
@@ -44,6 +50,8 @@ import {
|
||||
CreateProjectRequest,
|
||||
CreateActivityRequest,
|
||||
CreateMilestoneRequest,
|
||||
UpdateActivityRequest,
|
||||
UpdateMilestoneRequest,
|
||||
} from '../types';
|
||||
import {
|
||||
getIndustrySectorLabel,
|
||||
@@ -52,6 +60,7 @@ import {
|
||||
getPriorityBadge,
|
||||
formatDate,
|
||||
formatZAR,
|
||||
getUserEmail,
|
||||
INDUSTRY_SECTOR_OPTIONS,
|
||||
PROJECT_TYPE_OPTIONS,
|
||||
PRIORITY_OPTIONS,
|
||||
@@ -71,12 +80,14 @@ const activitySchema = z.object({
|
||||
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(),
|
||||
});
|
||||
|
||||
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>;
|
||||
@@ -91,10 +102,15 @@ export const ProjectsPage: React.FC = () => {
|
||||
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
|
||||
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
|
||||
|
||||
// Modals
|
||||
// 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({
|
||||
@@ -156,6 +172,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
description: '',
|
||||
category: '',
|
||||
amount: 0,
|
||||
milestoneId: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -165,21 +182,108 @@ export const ProjectsPage: React.FC = () => {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateActivity = () => {
|
||||
setEditingActivity(null);
|
||||
activityForm.reset({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
amount: 0,
|
||||
milestoneId: '',
|
||||
});
|
||||
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) : '',
|
||||
});
|
||||
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: CreateProjectRequest) => createProject(data),
|
||||
onSuccess: (newProj) => {
|
||||
mutationFn: (data: ProjectFormValues) => {
|
||||
if (editingProject) {
|
||||
return updateProject({
|
||||
projectId: editingProject.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
sector: Number(data.sector),
|
||||
projectType: Number(data.projectType),
|
||||
});
|
||||
}
|
||||
return createProject(data as CreateProjectRequest);
|
||||
},
|
||||
onSuccess: (savedProj) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success('Project registered', `Project "${newProj.name}" created successfully`);
|
||||
success(
|
||||
editingProject ? 'Project updated' : 'Project registered',
|
||||
`Project "${savedProj.name}" saved successfully`
|
||||
);
|
||||
setIsProjectModalOpen(false);
|
||||
setEditingProject(null);
|
||||
projectForm.reset();
|
||||
setSelectedProject(newProj);
|
||||
setSelectedProject(savedProj);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to create project', String(err));
|
||||
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,33 +298,88 @@ export const ProjectsPage: React.FC = () => {
|
||||
success('Status updated', 'Project governance status changed successfully');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to update status', String(err));
|
||||
toastError('Failed to update status', err);
|
||||
},
|
||||
});
|
||||
|
||||
const activityMutation = useMutation({
|
||||
mutationFn: (data: CreateActivityRequest) => createActivity(data),
|
||||
mutationFn: (data: ActivityFormValues) => {
|
||||
const parsedMilestoneId =
|
||||
data.milestoneId && String(data.milestoneId).trim() !== ''
|
||||
? Number(data.milestoneId)
|
||||
: 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,
|
||||
});
|
||||
}
|
||||
return createActivity({
|
||||
projectId: activeProjectId!,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: Number(data.amount),
|
||||
milestoneId: parsedMilestoneId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
|
||||
success('Activity added', 'Activity registered to project work breakdown');
|
||||
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('Failed to add activity', String(err));
|
||||
toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
|
||||
},
|
||||
});
|
||||
|
||||
const milestoneMutation = useMutation({
|
||||
mutationFn: (data: CreateMilestoneRequest) => createMilestone(data),
|
||||
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] });
|
||||
success('Milestone created', 'Contractual deliverable added to implementation schedule');
|
||||
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('Failed to add milestone', String(err));
|
||||
toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -247,7 +406,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsProjectModalOpen(true)}
|
||||
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" />
|
||||
@@ -331,27 +490,47 @@ export const ProjectsPage: React.FC = () => {
|
||||
</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>
|
||||
|
||||
{/* Status Change Selector */}
|
||||
<div className="shrink-0 text-right">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
|
||||
<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"
|
||||
{/* Actions & Status Change Selector */}
|
||||
<div className="shrink-0 text-right space-y-2">
|
||||
<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"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||
Edit Project
|
||||
</button>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
|
||||
<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>
|
||||
|
||||
@@ -433,7 +612,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
|
||||
<button
|
||||
onClick={() => setIsActivityModalOpen(true)}
|
||||
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" />
|
||||
@@ -445,19 +624,44 @@ export const ProjectsPage: React.FC = () => {
|
||||
<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) => (
|
||||
<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">
|
||||
<span className="font-semibold text-xs text-white">{a.title}</span>
|
||||
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
|
||||
{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>
|
||||
<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>
|
||||
<p className="text-xs text-slate-400 mt-1">{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>
|
||||
@@ -469,7 +673,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
|
||||
<button
|
||||
onClick={() => setIsMilestoneModalOpen(true)}
|
||||
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" />
|
||||
@@ -487,10 +691,22 @@ export const ProjectsPage: React.FC = () => {
|
||||
return (
|
||||
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-xs 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>
|
||||
<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">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
<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 justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -574,12 +790,15 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal: Create Project */}
|
||||
{/* Modal: Create or Edit Project */}
|
||||
<Modal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
title="Create Funded Project"
|
||||
subtitle="Operational entity under an approved Programme"
|
||||
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'}
|
||||
>
|
||||
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
@@ -595,19 +814,21 @@ export const ProjectsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
|
||||
<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>
|
||||
{!editingProject && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
|
||||
<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>
|
||||
@@ -652,7 +873,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProjectModalOpen(false)}
|
||||
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
|
||||
@@ -662,26 +886,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
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 ? 'Creating...' : 'Register Project'}
|
||||
{projectMutation.isPending ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Activity */}
|
||||
{/* Modal: Add or Edit Activity */}
|
||||
<Modal
|
||||
isOpen={isActivityModalOpen}
|
||||
onClose={() => setIsActivityModalOpen(false)}
|
||||
title="Add Activity to Implementation Plan"
|
||||
subtitle={`Registering activity under Project #${activeProjectId}`}
|
||||
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}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={activityForm.handleSubmit((data) =>
|
||||
activityMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...data,
|
||||
} as any)
|
||||
)}
|
||||
onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
@@ -694,6 +916,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Associated Milestone Dropdown (FR: Passing milestoneId to PUT/POST request) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Associated Milestone (Deliverable Link)
|
||||
</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Allocation (ZAR) *</label>
|
||||
@@ -726,7 +966,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActivityModalOpen(false)}
|
||||
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
|
||||
@@ -736,26 +979,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
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...' : 'Add Activity'}
|
||||
{activityMutation.isPending ? 'Saving...' : editingActivity ? 'Update Activity' : 'Add Activity'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Milestone */}
|
||||
{/* Modal: Add or Edit Milestone */}
|
||||
<Modal
|
||||
isOpen={isMilestoneModalOpen}
|
||||
onClose={() => setIsMilestoneModalOpen(false)}
|
||||
title="Define Contractual Milestone"
|
||||
subtitle={`Deliverable schedule for Project #${activeProjectId}`}
|
||||
onClose={() => {
|
||||
setIsMilestoneModalOpen(false);
|
||||
setEditingMilestone(null);
|
||||
}}
|
||||
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
|
||||
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={milestoneForm.handleSubmit((data) =>
|
||||
milestoneMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...data,
|
||||
} as any)
|
||||
)}
|
||||
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
@@ -792,10 +1033,29 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
<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)}
|
||||
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
|
||||
@@ -805,7 +1065,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
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...' : 'Define Milestone'}
|
||||
{milestoneMutation.isPending ? 'Saving...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user