382 lines
17 KiB
TypeScript
382 lines
17 KiB
TypeScript
import React from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
Flag,
|
|
Briefcase,
|
|
Target,
|
|
ShieldAlert,
|
|
FileCheck,
|
|
Edit2,
|
|
Calendar,
|
|
Clock,
|
|
Coins,
|
|
ChevronRight,
|
|
TrendingUp,
|
|
} from 'lucide-react';
|
|
import {
|
|
getActivities,
|
|
getMilestones,
|
|
getProjectIndicators,
|
|
getProjectRisks,
|
|
getBudgets,
|
|
getDisbursements,
|
|
} from '../../services/api';
|
|
import { Project, ApprovalStatus, Budget } from '../../types';
|
|
import {
|
|
formatDate,
|
|
formatZAR,
|
|
getApprovalStatusBadge,
|
|
getPriorityBadge,
|
|
getRiskScoreColor,
|
|
getUserEmail,
|
|
} from '../../lib/formatters';
|
|
import { ProjectTimelineGantt } from './ProjectTimelineGantt';
|
|
|
|
interface ProjectExpandedRowProps {
|
|
project: Project;
|
|
onOpenMilestonesModal: () => void;
|
|
onOpenActivitiesModal: () => void;
|
|
onOpenIndicatorsModal: () => void;
|
|
onOpenRisksModal: () => void;
|
|
onOpenEditProject: () => void;
|
|
}
|
|
|
|
export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|
project,
|
|
onOpenMilestonesModal,
|
|
onOpenActivitiesModal,
|
|
onOpenIndicatorsModal,
|
|
onOpenRisksModal,
|
|
onOpenEditProject,
|
|
}) => {
|
|
const projectId = project.id;
|
|
|
|
const { data: activities = [] } = useQuery({
|
|
queryKey: ['activities', projectId],
|
|
queryFn: () => getActivities(projectId),
|
|
});
|
|
|
|
const { data: milestones = [] } = useQuery({
|
|
queryKey: ['milestones', projectId],
|
|
queryFn: () => getMilestones(projectId),
|
|
});
|
|
|
|
const { data: indicators = [] } = useQuery({
|
|
queryKey: ['indicators', projectId],
|
|
queryFn: () => getProjectIndicators(projectId),
|
|
});
|
|
|
|
const { data: risks = [] } = useQuery({
|
|
queryKey: ['risks', projectId],
|
|
queryFn: () => getProjectRisks(projectId),
|
|
});
|
|
|
|
const { data: allBudgets = [] } = useQuery({
|
|
queryKey: ['budgets'],
|
|
queryFn: () => getBudgets(1, 100),
|
|
});
|
|
|
|
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);
|
|
|
|
const { data: disbursements = [] } = useQuery({
|
|
queryKey: ['disbursements', projectId],
|
|
queryFn: () => getDisbursements(1, 100),
|
|
});
|
|
|
|
const projectDisbursements = disbursements.filter(
|
|
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
|
);
|
|
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
|
|
|
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);
|
|
const projectValue = totalActivityBudget - totalDisbursed;
|
|
const projectVariance = approvedBudgetTotal - projectValue;
|
|
|
|
return (
|
|
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
|
{/* Project Meta Bar */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800 min-w-0">
|
|
<div className="space-y-1 min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<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 shrink-0">
|
|
Project #{project.id} Summary
|
|
</span>
|
|
<span className="text-xs text-slate-400 truncate">
|
|
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
|
</span>
|
|
{approvedBudgetTotal > 0 && (
|
|
<>
|
|
<span className="text-slate-600">•</span>
|
|
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
|
</span>
|
|
<span className="text-slate-600">•</span>
|
|
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
|
|
Project Value: {formatZAR(projectValue)}
|
|
</span>
|
|
<span className="text-slate-600">•</span>
|
|
<span
|
|
className={`text-xs font-mono font-semibold shrink-0 ${
|
|
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
|
}`}
|
|
title="Approved Budget minus Net Project Value"
|
|
>
|
|
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0 self-start lg:self-center flex-wrap">
|
|
<Link
|
|
to={`/monitoring?tab=evidence&projectId=${project.id}`}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 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 shrink-0"
|
|
>
|
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
|
Review Evidence Queue
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={onOpenEditProject}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 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 shrink-0"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5 text-slate-400" />
|
|
Edit Project Scope
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gantt-style Timeline Component */}
|
|
<ProjectTimelineGantt
|
|
project={project}
|
|
milestones={milestones}
|
|
activities={activities}
|
|
approvedBudgetTotal={approvedBudgetTotal}
|
|
approvedBudgetsCount={approvedBudgets.length}
|
|
onManageMilestones={onOpenMilestonesModal}
|
|
onManageActivities={onOpenActivitiesModal}
|
|
/>
|
|
|
|
{/* 4 Vital Resource Summary Cards Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 min-w-0">
|
|
{/* Card 1: Milestones */}
|
|
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3 min-w-0">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Flag className="w-4 h-4 text-blue-400" />
|
|
<span>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-3 text-center">No contractual milestones defined.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{milestones.slice(0, 3).map((m) => {
|
|
const sBadge = getApprovalStatusBadge(m.status);
|
|
return (
|
|
<div key={m.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
|
|
<div className="flex items-start justify-between gap-1">
|
|
<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="flex items-center justify-between text-[10px] text-slate-400 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-blue-400 font-bold">
|
|
{formatZAR(activities.filter(a => String(a.milestoneId) === String(m.id)).reduce((s, a) => s + (Number(a.amount) || 0), 0))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{milestones.length > 3 && (
|
|
<p className="text-[10px] text-slate-400 text-center">+{milestones.length - 3} 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 cursor-pointer"
|
|
>
|
|
<span>Manage Milestones</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 min-w-0">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Briefcase className="w-4 h-4 text-cyan-400" />
|
|
<span>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-3 text-center">No work activities recorded.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{activities.slice(0, 3).map((act) => (
|
|
<div key={act.id} className="p-2 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
|
|
<div className="flex items-start justify-between gap-1">
|
|
<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 > 3 && (
|
|
<p className="text-[10px] text-slate-400 text-center">+{activities.length - 3} 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 cursor-pointer"
|
|
>
|
|
<span>Manage 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 min-w-0">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<Target className="w-4 h-4 text-indigo-400" />
|
|
<span>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">
|
|
M&E Targets
|
|
</span>
|
|
</div>
|
|
|
|
{indicators.length === 0 ? (
|
|
<p className="text-xs text-slate-500 py-3 text-center">No M&E indicators defined.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{indicators.slice(0, 3).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 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
|
|
<div className="flex items-start justify-between gap-1">
|
|
<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 > 3 && (
|
|
<p className="text-[10px] text-slate-400 text-center">+{indicators.length - 3} 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 cursor-pointer"
|
|
>
|
|
<span>Manage 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 min-w-0">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-white">
|
|
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
|
<span>Risks ({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-3 text-center">No project hazards logged.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{risks.slice(0, 3).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 rounded-xl bg-slate-900/80 border border-slate-800/80 text-[11px] space-y-1">
|
|
<div className="flex items-start justify-between gap-1">
|
|
<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 > 3 && (
|
|
<p className="text-[10px] text-slate-400 text-center">+{risks.length - 3} 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 cursor-pointer"
|
|
>
|
|
<span>Manage Risks</span>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|