706 lines
31 KiB
TypeScript
706 lines
31 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
FolderKanban,
|
|
Flag,
|
|
Briefcase,
|
|
Target,
|
|
ShieldAlert,
|
|
FileCheck,
|
|
Edit2,
|
|
Calendar,
|
|
Clock,
|
|
Coins,
|
|
ChevronRight,
|
|
TrendingUp,
|
|
AlertTriangle,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
RotateCcw,
|
|
Sparkles,
|
|
ExternalLink,
|
|
SlidersHorizontal,
|
|
Lock,
|
|
} from 'lucide-react';
|
|
import { Modal } from '../ui/Modal';
|
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
|
import { useToast } from '../ui/Toast';
|
|
import { useRbac } from '../../auth/rbac';
|
|
import {
|
|
getActivities,
|
|
getMilestones,
|
|
getProjectIndicators,
|
|
getProjectRisks,
|
|
getBudgets,
|
|
updateProjectStatus,
|
|
} from '../../services/api';
|
|
import { Project, ApprovalStatus, Programme } from '../../types';
|
|
import {
|
|
formatDate,
|
|
formatZAR,
|
|
getApprovalStatusBadge,
|
|
getPriorityBadge,
|
|
getRiskScoreColor,
|
|
getUserEmail,
|
|
getIndustrySectorLabel,
|
|
getProjectTypeLabel,
|
|
APPROVAL_STATUS_OPTIONS,
|
|
} from '../../lib/formatters';
|
|
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
|
|
import { evaluateProjectScheduleRisk } from '../../pages/ProjectsPage';
|
|
|
|
interface ProjectViewModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
project: Project | null;
|
|
programmeMap: Map<string | number, Programme>;
|
|
onOpenMilestonesModal: () => void;
|
|
onOpenActivitiesModal: () => void;
|
|
onOpenIndicatorsModal: () => void;
|
|
onOpenRisksModal: () => void;
|
|
onOpenEditProject: () => void;
|
|
}
|
|
|
|
export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
project,
|
|
programmeMap,
|
|
onOpenMilestonesModal,
|
|
onOpenActivitiesModal,
|
|
onOpenIndicatorsModal,
|
|
onOpenRisksModal,
|
|
onOpenEditProject,
|
|
}) => {
|
|
const queryClient = useQueryClient();
|
|
const { success, error: toastError } = useToast();
|
|
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
|
|
|
const projectId = project?.id;
|
|
|
|
// Queries for project resources
|
|
const {
|
|
data: activities = [],
|
|
isLoading: loadingActivities,
|
|
refetch: refetchActivities,
|
|
} = useQuery({
|
|
queryKey: ['activities', projectId],
|
|
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const {
|
|
data: milestones = [],
|
|
isLoading: loadingMilestones,
|
|
refetch: refetchMilestones,
|
|
} = useQuery({
|
|
queryKey: ['milestones', projectId],
|
|
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const {
|
|
data: indicators = [],
|
|
isLoading: loadingIndicators,
|
|
refetch: refetchIndicators,
|
|
} = useQuery({
|
|
queryKey: ['indicators', projectId],
|
|
queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const {
|
|
data: risks = [],
|
|
isLoading: loadingRisks,
|
|
refetch: refetchRisks,
|
|
} = useQuery({
|
|
queryKey: ['risks', projectId],
|
|
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: allBudgets = [] } = useQuery({
|
|
queryKey: ['budgets'],
|
|
queryFn: () => getBudgets(1, 100),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
// Governance Status Mutation
|
|
const statusMutation = useMutation({
|
|
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
|
updateProjectStatus({ projectId, status }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
success('Status Updated', 'Project governance status updated successfully');
|
|
},
|
|
onError: (err: unknown) => {
|
|
toastError('Failed to update status', err);
|
|
},
|
|
});
|
|
|
|
// Calculate budgets
|
|
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
|
const approvedBudgets = projectBudgets.filter(
|
|
(b) =>
|
|
b.status === undefined ||
|
|
b.status === null ||
|
|
Number(b.status) === ApprovalStatus.Approved ||
|
|
Number(b.status) === 3
|
|
);
|
|
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
|
|
|
// Milestone Progress
|
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
|
|
|
// Schedule Risk
|
|
const scheduleRisk = useMemo(() => {
|
|
if (!project) return null;
|
|
return evaluateProjectScheduleRisk(project, { total: milestones.length, verified: verifiedMilestonesCount });
|
|
}, [project, milestones.length, verifiedMilestonesCount]);
|
|
|
|
// Total Duration
|
|
const durationDays = useMemo(() => {
|
|
if (!project?.startedAt || !project?.endedAt) return 0;
|
|
return Math.max(
|
|
1,
|
|
Math.round(
|
|
(new Date(project.endedAt).getTime() - new Date(project.startedAt).getTime()) /
|
|
(1000 * 60 * 60 * 24)
|
|
)
|
|
);
|
|
}, [project?.startedAt, project?.endedAt]);
|
|
|
|
const prog = project ? programmeMap.get(project.programmeId) : null;
|
|
|
|
if (!project) return null;
|
|
|
|
const canApproveThis = canApproveRecord('projects_milestones', project.createdBy);
|
|
const isSelf = isMaker(project.createdBy);
|
|
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
|
|
|
|
const handleRefreshAll = () => {
|
|
refetchActivities();
|
|
refetchMilestones();
|
|
refetchIndicators();
|
|
refetchRisks();
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title=""
|
|
size="full-screen"
|
|
zIndex="z-[9990]"
|
|
>
|
|
<div className="space-y-6 -mt-3 text-slate-200">
|
|
{/* Top Header Banner: Project Title, ID, Sector, Status, and Primary Actions */}
|
|
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-5 sm:p-6 rounded-2xl border border-slate-800 shadow-xl relative overflow-hidden">
|
|
<div className="absolute -right-8 -bottom-8 w-48 h-48 bg-blue-600/10 rounded-full blur-2xl pointer-events-none" />
|
|
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 relative z-10">
|
|
{/* Project Meta Info */}
|
|
<div className="space-y-2 min-w-0 flex-1">
|
|
<div className="flex items-center gap-2.5 flex-wrap">
|
|
<span className="text-xs font-mono font-bold text-blue-400 bg-blue-500/15 px-2.5 py-0.5 rounded-lg border border-blue-500/30">
|
|
Project #{project.id}
|
|
</span>
|
|
<span className="text-xs font-semibold text-slate-300 bg-slate-900/90 px-2.5 py-0.5 rounded-lg border border-slate-700/80">
|
|
{getIndustrySectorLabel(project.sector)}
|
|
</span>
|
|
<span className="text-xs text-slate-400">
|
|
{getProjectTypeLabel(project.projectType)}
|
|
</span>
|
|
<span className="text-slate-500">•</span>
|
|
<span className="text-xs text-slate-300">
|
|
Programme:{' '}
|
|
<strong className="text-blue-300">
|
|
{prog?.name || `Prog #${project.programmeId}`}
|
|
</strong>
|
|
</span>
|
|
</div>
|
|
|
|
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight leading-snug">
|
|
{project.name || `Project #${project.id}`}
|
|
</h2>
|
|
|
|
<div className="flex items-center gap-3 text-xs text-slate-400 flex-wrap pt-0.5">
|
|
<span>
|
|
Owner:{' '}
|
|
<strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
|
</span>
|
|
<span className="text-slate-600">•</span>
|
|
<div className="flex items-center gap-1 font-mono text-slate-300">
|
|
<Calendar className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
|
<span>
|
|
{formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days)
|
|
</span>
|
|
</div>
|
|
{approvedBudgetTotal > 0 && (
|
|
<>
|
|
<span className="text-slate-600">•</span>
|
|
<span className="text-emerald-400 font-mono font-bold">
|
|
Budget: {formatZAR(approvedBudgetTotal)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status & Action Controls */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 shrink-0">
|
|
{/* Schedule Risk Pill */}
|
|
{scheduleRisk && (
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold border ${scheduleRisk.badgeClass}`}
|
|
title={scheduleRisk.reason}
|
|
>
|
|
{scheduleRisk.isOverdue ? (
|
|
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
|
) : scheduleRisk.isAtRisk ? (
|
|
<AlertCircle className="w-4 h-4 text-amber-400" />
|
|
) : (
|
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
)}
|
|
<span>
|
|
{scheduleRisk.isOverdue
|
|
? 'Overdue Schedule'
|
|
: scheduleRisk.isAtRisk
|
|
? `At Risk (${scheduleRisk.daysRemaining}d)`
|
|
: 'On Track'}
|
|
</span>
|
|
</span>
|
|
<HelpTooltip
|
|
title="Schedule Risk Assessment"
|
|
content={scheduleRisk.reason}
|
|
size="sm"
|
|
placement="bottom"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Real-time Governance Status Dropdown / Immutable Locked State */}
|
|
<div className="min-w-[140px]">
|
|
{Number(project.status) === ApprovalStatus.Approved ? (
|
|
<div
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 shadow-sm cursor-default"
|
|
title="Approved records are immutable. Status cannot be modified."
|
|
>
|
|
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
|
<span>Approved (Locked)</span>
|
|
</div>
|
|
) : (
|
|
<select
|
|
value={project.status}
|
|
disabled={!canModifyStatus}
|
|
onChange={(e) =>
|
|
statusMutation.mutate({
|
|
projectId: project.id,
|
|
status: Number(e.target.value) as ApprovalStatus,
|
|
})
|
|
}
|
|
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/90 rounded-xl px-2.5 py-2 text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate shadow-md"
|
|
title="Project Governance Clearance Status"
|
|
>
|
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
|
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
|
let reasonText = '';
|
|
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
|
reasonText = ' (Requires Update)';
|
|
} else if (isApprovedOpt && isSelf) {
|
|
reasonText = ' (Maker-Checker)';
|
|
}
|
|
return (
|
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
|
{opt.label}{reasonText}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shadow-xs"
|
|
title="Review Evidence Queue"
|
|
>
|
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
|
<span className="hidden sm:inline">Evidence Queue</span>
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenEditProject}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer shadow-xs"
|
|
title="Edit Project Scope & Schedule"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
|
<span className="hidden sm:inline">Edit Scope</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleRefreshAll}
|
|
className="p-2 rounded-xl text-blue-400 hover:text-blue-300 bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 transition cursor-pointer shadow-xs"
|
|
title="Refresh project datasets"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project Narrative */}
|
|
{project.description && (
|
|
<div className="mt-3.5 pt-3 border-t border-slate-800/80 text-xs text-slate-300 leading-relaxed max-w-5xl">
|
|
<span className="font-semibold text-slate-400 mr-1.5">Scope Narrative:</span>
|
|
{project.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 5 Vital Project KPI Metrics Bar */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3.5">
|
|
{/* Project Duration */}
|
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[10px] uppercase font-bold tracking-wider">Project Duration</span>
|
|
<Calendar className="w-3.5 h-3.5 text-blue-400" />
|
|
</div>
|
|
<span className="text-base font-bold text-white mt-1 block font-mono">
|
|
{durationDays} Days
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
|
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Milestone Progress */}
|
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[10px] uppercase font-bold tracking-wider">Milestone Progress</span>
|
|
<Flag className="w-3.5 h-3.5 text-emerald-400" />
|
|
</div>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-base font-bold text-emerald-400">{milestoneProgress}%</span>
|
|
<span className="text-xs text-slate-400">
|
|
({verifiedMilestonesCount}/{milestones.length} Verified)
|
|
</span>
|
|
</div>
|
|
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-2 overflow-hidden border border-slate-800">
|
|
<div
|
|
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
|
style={{ width: `${milestoneProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Work Breakdown Cost */}
|
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[10px] uppercase font-bold tracking-wider">Work Breakdown Cost</span>
|
|
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
|
</div>
|
|
<span className="text-base font-bold text-cyan-400 mt-1 block font-mono">
|
|
{formatZAR(totalActivityBudget)}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
|
{activities.length} total recorded tasks
|
|
</span>
|
|
</div>
|
|
|
|
{/* Governance State */}
|
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[10px] uppercase font-bold tracking-wider">Governance State</span>
|
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
|
|
</div>
|
|
<div className="mt-1">
|
|
{(() => {
|
|
const b = getApprovalStatusBadge(project.status);
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${b.className}`}
|
|
>
|
|
{b.label}
|
|
</span>
|
|
);
|
|
})()}
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 mt-1 block">Statutory Clearance Gate</span>
|
|
</div>
|
|
|
|
{/* Approved Budget Total */}
|
|
<div className="p-3.5 bg-[#0d121f] rounded-2xl border border-slate-800 shadow-md">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[10px] uppercase font-bold tracking-wider">Approved Budget Value</span>
|
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
|
</div>
|
|
<span className="text-base font-bold text-emerald-400 mt-1 block font-mono">
|
|
{formatZAR(approvedBudgetTotal)}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 block truncate mt-0.5">
|
|
{approvedBudgets.length > 0
|
|
? `${approvedBudgets.length} approved budget line${approvedBudgets.length === 1 ? '' : 's'}`
|
|
: 'Allocated Project Budget'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Navigable Timeline Gantt Chart Component (Requirements 4, 5, 6) */}
|
|
<ProjectTimelineGantt
|
|
project={project}
|
|
milestones={milestones}
|
|
activities={activities}
|
|
approvedBudgetTotal={approvedBudgetTotal}
|
|
approvedBudgetsCount={approvedBudgets.length}
|
|
onManageMilestones={onOpenMilestonesModal}
|
|
onManageActivities={onOpenActivitiesModal}
|
|
/>
|
|
|
|
{/* 4 Vital Resource Summary Cards (Milestones, Activities, Indicators, Risks) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4">
|
|
{/* Card 1: Milestones */}
|
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Flag className="w-4 h-4 text-blue-400" />
|
|
<span>Contractual Milestones ({milestones.length})</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-emerald-400 font-bold bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
|
|
{verifiedMilestonesCount}/{milestones.length} Verified
|
|
</span>
|
|
</div>
|
|
|
|
{milestones.length === 0 ? (
|
|
<p className="text-xs text-slate-500 py-4 text-center">No contractual milestones defined.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{milestones.slice(0, 4).map((m) => {
|
|
const sBadge = getApprovalStatusBadge(m.status);
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-semibold text-slate-200 truncate block">{m.name}</span>
|
|
<span className={`shrink-0 text-[9px] px-1.5 py-0.2 rounded border ${sBadge.className}`}>
|
|
{sBadge.label}
|
|
</span>
|
|
</div>
|
|
<div className="text-[10px] text-slate-400 flex items-center justify-between font-mono">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3 text-slate-500" />
|
|
Due: {formatDate(m.dueAt)}
|
|
</span>
|
|
<span className="text-slate-500">#{m.id}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{milestones.length > 4 && (
|
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
|
+{milestones.length - 4} more deliverables
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenMilestonesModal}
|
|
className="w-full py-2 px-3 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
|
>
|
|
<span>Manage Milestones & Deliverables</span>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Card 2: Activities */}
|
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Briefcase className="w-4 h-4 text-cyan-400" />
|
|
<span>Work Activities ({activities.length})</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-cyan-400 font-bold bg-cyan-500/10 px-2 py-0.5 rounded-full border border-cyan-500/30">
|
|
{formatZAR(totalActivityBudget)}
|
|
</span>
|
|
</div>
|
|
|
|
{activities.length === 0 ? (
|
|
<p className="text-xs text-slate-500 py-4 text-center">No work activities recorded.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{activities.slice(0, 4).map((act) => (
|
|
<div
|
|
key={act.id}
|
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
|
<span className="font-mono text-cyan-400 font-bold shrink-0">{formatZAR(act.amount)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
|
<span>{act.category || 'Operations'}</span>
|
|
<span>{formatDate(act.performedAt)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{activities.length > 4 && (
|
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
|
+{activities.length - 4} more tasks
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenActivitiesModal}
|
|
className="w-full py-2 px-3 text-xs font-semibold text-cyan-400 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
|
>
|
|
<span>Manage Work Activities</span>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Card 3: Indicators */}
|
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Target className="w-4 h-4 text-indigo-400" />
|
|
<span>M&E Indicators ({indicators.length})</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-indigo-400 font-bold bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/30">
|
|
Results Chain
|
|
</span>
|
|
</div>
|
|
|
|
{indicators.length === 0 ? (
|
|
<p className="text-xs text-slate-500 py-4 text-center">No M&E indicators defined.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{indicators.slice(0, 4).map((ind) => {
|
|
const target = Number(ind.targetAmount) || 1;
|
|
const actual = Number(ind.actualAmount) || 0;
|
|
const pct = Math.round((actual / target) * 100);
|
|
return (
|
|
<div
|
|
key={ind.id}
|
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-semibold text-slate-200 truncate block">{ind.name}</span>
|
|
<span
|
|
className={
|
|
pct >= 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold'
|
|
}
|
|
>
|
|
{pct}%
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
|
<span>
|
|
Actual: <strong className="text-slate-200 font-mono">{actual}</strong>
|
|
</span>
|
|
<span>
|
|
Target: <strong className="text-slate-200 font-mono">{target}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{indicators.length > 4 && (
|
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
|
+{indicators.length - 4} more indicators
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenIndicatorsModal}
|
|
className="w-full py-2 px-3 text-xs font-semibold text-indigo-400 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
|
>
|
|
<span>Manage M&E Indicators</span>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Card 4: Risks */}
|
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 shadow-md">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2.5">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
|
<span>Risk Register ({risks.length})</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/30">
|
|
ISO 31000
|
|
</span>
|
|
</div>
|
|
|
|
{risks.length === 0 ? (
|
|
<p className="text-xs text-slate-500 py-4 text-center">No project hazards logged.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{risks.slice(0, 4).map((rk) => {
|
|
const score = (rk.likelihood || 1) * (rk.impact || 1);
|
|
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
|
|
return (
|
|
<div
|
|
key={rk.id}
|
|
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-semibold text-slate-200 truncate block">{rk.name}</span>
|
|
<span className={`text-[9px] px-1.5 py-0.2 rounded font-mono ${riskInfo.badge}`}>
|
|
{score}/25 {riskInfo.level}
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] text-slate-400 line-clamp-1">{rk.description}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
{risks.length > 4 && (
|
|
<p className="text-[10px] text-slate-400 text-center font-medium">
|
|
+{risks.length - 4} more risks
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenRisksModal}
|
|
className="w-full py-2 px-3 text-xs font-semibold text-rose-400 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition flex items-center justify-center gap-1.5 cursor-pointer shadow-xs"
|
|
>
|
|
<span>Manage Risk Register</span>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|