From 8b68f7b99caaf63e4868a4c522e01c7029c3fcf0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 28 Sep 2026 11:34:11 +0200 Subject: [PATCH] Refactored projects and milestones page --- .../projects/ProjectActivitiesModal.tsx | 426 ++++ .../projects/ProjectExpandedRow.tsx | 324 +++ .../components/projects/ProjectFormModal.tsx | 287 +++ .../projects/ProjectIndicatorsModal.tsx | 396 ++++ .../projects/ProjectMilestonesModal.tsx | 384 ++++ .../components/projects/ProjectRisksModal.tsx | 367 ++++ .../projects/ProjectTimelineGantt.tsx | 415 ++++ .../src/pages/ProjectsPage.tsx | 1893 ++++++----------- 8 files changed, 3307 insertions(+), 1185 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectFormModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectIndicatorsModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectRisksModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectTimelineGantt.tsx diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx new file mode 100644 index 0000000..be5a9ea --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx @@ -0,0 +1,426 @@ +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 { + Briefcase, + Plus, + Edit2, + Clock, + Coins, + FileCheck, + Flag, + X, + RotateCcw, +} from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { HelpTooltip } from '../ui/HelpTooltip'; +import { useToast } from '../ui/Toast'; +import { + getActivities, + createActivity, + updateActivity, + getMilestones, +} from '../../services/api'; +import { Project, Activity, Milestone } from '../../types'; +import { formatDate, formatZAR } from '../../lib/formatters'; + +const activitySchema = z.object({ + title: z.string().min(3, 'Activity title required'), + 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(), + performedAt: z.string().optional().nullable(), +}); + +type ActivityFormValues = z.infer; + +interface ProjectActivitiesModalProps { + isOpen: boolean; + onClose: () => void; + project: Project | null; +} + +export const ProjectActivitiesModal: React.FC = ({ + isOpen, + onClose, + project, +}) => { + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingActivity, setEditingActivity] = useState(null); + + const projectId = project?.id; + + const { + data: activities = [], + isLoading, + refetch, + } = useQuery({ + queryKey: ['activities', projectId], + queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const { data: milestones = [] } = useQuery({ + queryKey: ['milestones', projectId], + queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const form = useForm({ + resolver: zodResolver(activitySchema) as any, + defaultValues: { + title: '', + description: '', + category: 'Operations', + amount: 0, + milestoneId: '', + performedAt: new Date().toISOString().slice(0, 10), + }, + }); + + const openCreateForm = () => { + setEditingActivity(null); + form.reset({ + title: '', + description: '', + category: 'Operations', + amount: 0, + milestoneId: '', + performedAt: new Date().toISOString().slice(0, 10), + }); + setIsFormOpen(true); + }; + + const openEditForm = (act: Activity) => { + setEditingActivity(act); + form.reset({ + title: act.title || '', + description: act.description || '', + category: act.category || 'Operations', + amount: act.amount || 0, + milestoneId: act.milestoneId ? String(act.milestoneId) : '', + performedAt: act.performedAt ? act.performedAt.slice(0, 10) : '', + }); + setIsFormOpen(true); + }; + + const mutation = useMutation({ + mutationFn: async (data: ActivityFormValues): Promise => { + if (!projectId) return; + const parsedMilestoneId = + data.milestoneId && String(data.milestoneId).trim() !== '' + ? Number(data.milestoneId) + : null; + const performedAtIso = data.performedAt ? new Date(data.performedAt).toISOString() : null; + + if (editingActivity) { + await updateActivity({ + activityId: editingActivity.id, + projectId: projectId, + title: data.title, + description: data.description, + category: data.category, + amount: Number(data.amount), + milestoneId: parsedMilestoneId, + performedAt: performedAtIso, + }); + } else { + await createActivity({ + projectId: projectId, + title: data.title, + description: data.description, + category: data.category, + amount: Number(data.amount), + milestoneId: parsedMilestoneId, + performedAt: performedAtIso, + }); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['activities', projectId] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + success( + editingActivity ? 'Activity Updated' : 'Activity Created', + editingActivity + ? 'Work breakdown task updated successfully' + : 'Task added to project work breakdown structure' + ); + setIsFormOpen(false); + setEditingActivity(null); + form.reset(); + }, + onError: (err: unknown) => { + toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err); + }, + }); + + if (!project) return null; + + const totalBudget = activities.reduce((acc, a) => acc + (Number(a.amount) || 0), 0); + + return ( + { + setIsFormOpen(false); + setEditingActivity(null); + onClose(); + }} + title={`Work Breakdown Activities: ${project.name}`} + subtitle={`Project #${project.id} • Operational Work Breakdown Structure (WBS)`} + helpText="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones and are tracked with specific budget allocations." + maxWidth="xl" + > +
+ {/* KPI Summary Card */} +
+
+ + + Total Allocated Work Budget + +
+ {formatZAR(totalBudget)} + ({activities.length} recorded tasks) +
+
+ +
+ {!isFormOpen && ( + + )} +
+
+ + {/* Create/Edit Activity Form */} + {isFormOpen && ( +
+
+ + + {editingActivity ? `Edit Activity #${editingActivity.id}` : 'Add Work Breakdown Activity'} + + +
+ +
mutation.mutate(data as any))} className="space-y-3"> +
+ + + {form.formState.errors.title && ( +

{form.formState.errors.title.message}

+ )} +
+ +
+ + +
+ +
+
+ + + {form.formState.errors.amount && ( +

{form.formState.errors.amount.message}

+ )} +
+ +
+ + + {form.formState.errors.category && ( +

{form.formState.errors.category.message}

+ )} +
+
+ +
+ + +
+ +
+ +