Files
pfm/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx
T
hermes ec777fabf8 Implemented session and account lockout management
Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
2026-09-18 10:19:24 +02:00

1076 lines
47 KiB
TypeScript

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 {
FolderKanban,
Plus,
CheckCircle2,
Calendar,
Layers,
ChevronRight,
Clock,
Briefcase,
AlertCircle,
Flag,
FileCheck,
TrendingUp,
X,
Edit2,
} from 'lucide-react';
import {
getProjects,
createProject,
updateProject,
updateProjectStatus,
getActivities,
createActivity,
updateActivity,
getMilestones,
createMilestone,
updateMilestone,
getAllProgrammes,
getProgrammeProjects,
getActivity,
getMilestone,
getProjectIndicators,
getProjectRisks,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import {
Project,
Activity,
Milestone,
IndustrySector,
ProjectType,
ApprovalStatus,
Priority,
CreateProjectRequest,
CreateActivityRequest,
CreateMilestoneRequest,
UpdateActivityRequest,
UpdateMilestoneRequest,
} from '../types';
import {
getIndustrySectorLabel,
getProjectTypeLabel,
getApprovalStatusBadge,
getPriorityBadge,
formatDate,
formatZAR,
getUserEmail,
INDUSTRY_SECTOR_OPTIONS,
PROJECT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
APPROVAL_STATUS_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'),
programmeId: z.coerce.number().min(1, 'Select a programme'),
sector: z.coerce.number().min(1),
projectType: z.coerce.number().min(1),
});
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(),
});
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>;
type ActivityFormValues = z.infer<typeof activitySchema>;
type MilestoneFormValues = z.infer<typeof milestoneSchema>;
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
// 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({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects', selectedProgrammeFilter],
queryFn: () =>
selectedProgrammeFilter !== 'all'
? getProgrammeProjects(selectedProgrammeFilter)
: getProjects(),
});
// Details Queries (for currently selected project)
const activeProjectId = selectedProject ? selectedProject.id : null;
const { data: activities = [] } = useQuery({
queryKey: ['activities', activeProjectId],
queryFn: () => (activeProjectId ? getActivities(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones', activeProjectId],
queryFn: () => (activeProjectId ? getMilestones(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: indicators = [] } = useQuery({
queryKey: ['indicators', activeProjectId],
queryFn: () => (activeProjectId ? getProjectIndicators(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: risks = [] } = useQuery({
queryKey: ['risks', activeProjectId],
queryFn: () => (activeProjectId ? getProjectRisks(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
// Forms
const projectForm = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema) as any,
defaultValues: {
name: '',
description: '',
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
},
});
const activityForm = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema) as any,
defaultValues: {
title: '',
description: '',
category: '',
amount: 0,
milestoneId: '',
},
});
const milestoneForm = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema) as any,
defaultValues: {
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: 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(
editingProject ? 'Project updated' : 'Project registered',
`Project "${savedProj.name}" saved successfully`
);
setIsProjectModalOpen(false);
setEditingProject(null);
projectForm.reset();
setSelectedProject(savedProj);
},
onError: (err: unknown) => {
toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err);
},
});
const statusMutation = useMutation({
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
updateProjectStatus({ projectId, status }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
if (selectedProject) {
setSelectedProject({ ...selectedProject, status: variables.status });
}
success('Status updated', 'Project governance status changed successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', err);
},
});
const activityMutation = useMutation({
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(
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(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err);
},
});
const milestoneMutation = useMutation({
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] });
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(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err);
},
});
// Calculate project progress based on milestones (FR-M04-018)
const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0;
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
Project & Milestone Management
</h2>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Work Breakdown
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
</p>
</div>
<button
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" />
Create Project
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Project Directory List */}
<div className="lg:col-span-5 space-y-3">
<div className="flex items-center justify-between gap-2 px-1">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Projects Directory ({projects.length})
</h3>
<select
value={selectedProgrammeFilter}
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[190px] [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Programmes</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
{loadingProjects ? (
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
Loading projects...
</div>
) : (
<div className="space-y-2.5">
{projects.map((p) => {
const isSelected = selectedProject?.id === p.id;
const statusBadge = getApprovalStatusBadge(p.status);
return (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${
isSelected
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-bold text-sm text-white leading-snug">{p.name}</span>
<span className={`shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label.split('/')[0]}
</span>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">{p.description}</p>
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-slate-800/80 text-[11px] text-slate-400">
<span className="bg-slate-900/80 text-slate-300 border border-slate-700/80 px-2 py-0.5 rounded-md font-medium">
{getIndustrySectorLabel(p.sector)}
</span>
<span>&bull;</span>
<span className="text-slate-300">{getProjectTypeLabel(p.projectType)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Column: Detailed Project Workspace */}
<div className="lg:col-span-7">
{selectedProject ? (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-xl backdrop-blur-md overflow-hidden sticky top-6">
{/* Project Header */}
<div className="p-6 border-b border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between gap-3">
<div>
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
Project #{selectedProject.id}
</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>&bull;</span>
<span>
Sector: <strong className="text-slate-200 font-normal">{getIndustrySectorLabel(selectedProject.sector)}</strong>
</span>
</div>
</div>
{/* 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"
>
<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>
{/* Progress bar (FR-M04-018: calculated from approved milestones) */}
<div className="mt-5 pt-4 border-t border-slate-800/80">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="font-semibold text-slate-300 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Progress (Milestone-based)
</span>
<span className="font-bold text-blue-400">{milestoneProgress}%</span>
</div>
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 rounded-full"
style={{ width: `${milestoneProgress}%` }}
></div>
</div>
<div className="flex justify-between text-[10px] text-slate-500 mt-1">
<span>{completedMilestones} of {milestones.length} milestones verified</span>
<span>Target: 100% completion</span>
</div>
</div>
</div>
{/* Sub-tabs */}
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
<button
onClick={() => setDetailTab('activities')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'activities'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Briefcase className="w-3.5 h-3.5" />
Work Activities ({activities.length})
</button>
<button
onClick={() => setDetailTab('milestones')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'milestones'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Flag className="w-3.5 h-3.5" />
Milestones ({milestones.length})
</button>
<button
onClick={() => setDetailTab('indicators')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'indicators'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<FileCheck className="w-3.5 h-3.5" />
Indicators ({indicators.length})
</button>
<button
onClick={() => setDetailTab('risks')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
detailTab === 'risks'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<AlertCircle className="w-3.5 h-3.5" />
Risks ({risks.length})
</button>
</div>
{/* Tab Content */}
<div className="p-5 max-h-[500px] overflow-y-auto">
{/* Activities Sub-tab */}
{detailTab === 'activities' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<button
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" />
Add Activity
</button>
</div>
{activities.length === 0 ? (
<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) => {
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>
);
})}
</div>
)}
</div>
)}
{/* Milestones Sub-tab */}
{detailTab === 'milestones' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<button
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" />
Add Milestone
</button>
</div>
{milestones.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No milestones scheduled.</p>
) : (
<div className="space-y-2.5">
{milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
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">
<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">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Indicators Sub-tab */}
{detailTab === 'indicators' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No indicators attached.</p>
) : (
indicators.map((ind) => {
const pct = ind.targetAmount > 0 ? Math.round((ind.actualAmount / ind.targetAmount) * 100) : 0;
return (
<div key={ind.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1.5">
<div className="flex justify-between text-xs font-semibold">
<span className="text-white">{ind.name}</span>
<span className={pct >= 90 ? 'text-emerald-400' : 'text-amber-400'}>{pct}%</span>
</div>
<div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: {ind.baselineAmount}</span>
<span>Target: {ind.targetAmount}</span>
<span className="font-bold text-slate-200">Actual: {ind.actualAmount}</span>
</div>
</div>
);
})
)}
</div>
)}
{/* Risks Sub-tab */}
{detailTab === 'risks' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No project risks logged.</p>
) : (
risks.map((rk) => (
<div key={rk.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1">
<div className="flex justify-between text-xs font-semibold text-white">
<span>{rk.name}</span>
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-mono">
Score: {rk.likelihood * rk.impact} / 25
</span>
</div>
<p className="text-xs text-slate-400">{rk.description}</p>
{rk.mitigationAction && (
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2 rounded-lg mt-1.5 border border-emerald-500/20">
<strong className="text-emerald-300">Mitigation:</strong> {rk.mitigationAction}
</p>
)}
</div>
))
)}
</div>
)}
</div>
</div>
) : (
<div className="bg-[#0f1523]/80 rounded-2xl border border-dashed border-slate-800 p-12 text-center text-slate-400 flex flex-col items-center justify-center">
<FolderKanban className="w-10 h-10 text-slate-600 mb-3" />
<p className="text-sm font-semibold text-slate-200">Select a project to inspect implementation</p>
<p className="text-xs text-slate-500 mt-1">View activities, milestones, indicators, and risk controls.</p>
</div>
)}
</div>
</div>
{/* Modal: Create or Edit Project */}
<Modal
isOpen={isProjectModalOpen}
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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Name *</label>
<input
type="text"
{...projectForm.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"
/>
{projectForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{projectForm.formState.errors.name.message}</p>
)}
</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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Industry Sector *</label>
<select
{...projectForm.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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Type *</label>
<select
{...projectForm.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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Scope & Objectives *</label>
<textarea
rows={3}
{...projectForm.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..."
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
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
</button>
<button
type="submit"
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 ? 'Saving...' : editingProject ? 'Update Project' : 'Register Project'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add or Edit Activity */}
<Modal
isOpen={isActivityModalOpen}
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(data as any))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
<input
type="text"
{...activityForm.register('title')}
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. Cleanroom Sensor Calibration"
/>
</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>
<input
type="number"
{...activityForm.register('amount')}
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 font-mono text-white bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Cost Category *</label>
<input
type="text"
{...activityForm.register('category')}
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="Hardware / Field Testing"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
<textarea
rows={2}
{...activityForm.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]"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
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
</button>
<button
type="submit"
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...' : editingActivity ? 'Update Activity' : 'Add Activity'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add or Edit Milestone */}
<Modal
isOpen={isMilestoneModalOpen}
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(data as any))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Milestone Deliverable Name *</label>
<input
type="text"
{...milestoneForm.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. Milestone 4: Prototype Pilot Validation Report"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Contractual Due Date *</label>
<input
type="date"
{...milestoneForm.register('dueAt')}
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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority Level *</label>
<select
{...milestoneForm.register('priority')}
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"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</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);
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
</button>
<button
type="submit"
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...' : editingMilestone ? 'Update Milestone' : 'Define Milestone'}
</button>
</div>
</form>
</Modal>
</div>
);
};