Added PFM Console UI project
This commit is contained in:
@@ -0,0 +1,789 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
createProject,
|
||||
updateProjectStatus,
|
||||
getActivities,
|
||||
createActivity,
|
||||
getMilestones,
|
||||
createMilestone,
|
||||
getAllProgrammes,
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import {
|
||||
Project,
|
||||
IndustrySector,
|
||||
ProjectType,
|
||||
ApprovalStatus,
|
||||
Priority,
|
||||
CreateProjectRequest,
|
||||
CreateActivityRequest,
|
||||
CreateMilestoneRequest,
|
||||
} from '../types';
|
||||
import {
|
||||
getIndustrySectorLabel,
|
||||
getProjectTypeLabel,
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
formatDate,
|
||||
formatZAR,
|
||||
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'),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Modals
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
|
||||
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
|
||||
const { data: programmes = [] } = useQuery({
|
||||
queryKey: ['programmes'],
|
||||
queryFn: () => getAllProgrammes(),
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const projectMutation = useMutation({
|
||||
mutationFn: (data: CreateProjectRequest) => createProject(data),
|
||||
onSuccess: (newProj) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
success('Project registered', `Project "${newProj.name}" created successfully`);
|
||||
setIsProjectModalOpen(false);
|
||||
projectForm.reset();
|
||||
setSelectedProject(newProj);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to create project', String(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', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const activityMutation = useMutation({
|
||||
mutationFn: (data: CreateActivityRequest) => createActivity(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
|
||||
success('Activity added', 'Activity registered to project work breakdown');
|
||||
setIsActivityModalOpen(false);
|
||||
activityForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to add activity', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const milestoneMutation = useMutation({
|
||||
mutationFn: (data: CreateMilestoneRequest) => createMilestone(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
|
||||
success('Milestone created', 'Contractual deliverable added to implementation schedule');
|
||||
setIsMilestoneModalOpen(false);
|
||||
milestoneForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to add milestone', String(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={() => setIsProjectModalOpen(true)}
|
||||
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">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider px-1">
|
||||
Projects Directory ({projects.length})
|
||||
</h3>
|
||||
|
||||
{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>•</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>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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={() => setIsActivityModalOpen(true)}
|
||||
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) => (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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={() => setIsMilestoneModalOpen(true)}
|
||||
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">
|
||||
<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>
|
||||
<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 Project */}
|
||||
<Modal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
title="Create Funded Project"
|
||||
subtitle="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>
|
||||
|
||||
<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)}
|
||||
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 ? 'Creating...' : 'Register Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Activity */}
|
||||
<Modal
|
||||
isOpen={isActivityModalOpen}
|
||||
onClose={() => setIsActivityModalOpen(false)}
|
||||
title="Add Activity to Implementation Plan"
|
||||
subtitle={`Registering activity under Project #${activeProjectId}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={activityForm.handleSubmit((data) =>
|
||||
activityMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...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>
|
||||
|
||||
<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)}
|
||||
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...' : 'Add Activity'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Modal: Add Milestone */}
|
||||
<Modal
|
||||
isOpen={isMilestoneModalOpen}
|
||||
onClose={() => setIsMilestoneModalOpen(false)}
|
||||
title="Define Contractual Milestone"
|
||||
subtitle={`Deliverable schedule for Project #${activeProjectId}`}
|
||||
>
|
||||
<form
|
||||
onSubmit={milestoneForm.handleSubmit((data) =>
|
||||
milestoneMutation.mutate({
|
||||
projectId: activeProjectId!,
|
||||
...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>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMilestoneModalOpen(false)}
|
||||
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...' : 'Define Milestone'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user