Merge pull request 'Fixed timeline height' (#121) from finishline into main
Reviewed-on: #121
This commit was merged in pull request #121.
This commit is contained in:
@@ -19,8 +19,9 @@ import {
|
|||||||
getMilestones,
|
getMilestones,
|
||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
|
getBudgets,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, ApprovalStatus } from '../../types';
|
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatZAR,
|
formatZAR,
|
||||||
@@ -70,6 +71,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
queryFn: () => getProjectRisks(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 verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
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 totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
@@ -86,6 +102,14 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
Owner / Created by: <strong className="text-slate-200">{getUserEmail(project.createdBy)}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
{approvedBudgetTotal > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className="text-xs text-emerald-400 font-mono font-semibold">
|
||||||
|
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
<p className="text-xs text-slate-300 leading-relaxed">{project.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +138,8 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
|||||||
project={project}
|
project={project}
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
activities={activities}
|
activities={activities}
|
||||||
|
approvedBudgetTotal={approvedBudgetTotal}
|
||||||
|
approvedBudgetsCount={approvedBudgets.length}
|
||||||
onManageMilestones={onOpenMilestonesModal}
|
onManageMilestones={onOpenMilestonesModal}
|
||||||
onManageActivities={onOpenActivitiesModal}
|
onManageActivities={onOpenActivitiesModal}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface ProjectTimelineGanttProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
approvedBudgetTotal?: number;
|
||||||
|
approvedBudgetsCount?: number;
|
||||||
onManageMilestones: () => void;
|
onManageMilestones: () => void;
|
||||||
onManageActivities: () => void;
|
onManageActivities: () => void;
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
project,
|
project,
|
||||||
milestones,
|
milestones,
|
||||||
activities,
|
activities,
|
||||||
|
approvedBudgetTotal = 0,
|
||||||
|
approvedBudgetsCount = 0,
|
||||||
onManageMilestones,
|
onManageMilestones,
|
||||||
onManageActivities,
|
onManageActivities,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -362,7 +366,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Summary KPI Bar */}
|
{/* Bottom Summary KPI Bar */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 pt-2">
|
||||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
||||||
<span className="text-sm font-bold text-white mt-0.5 block">
|
<span className="text-sm font-bold text-white mt-0.5 block">
|
||||||
@@ -379,7 +383,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
|
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
|
||||||
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
|
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-1.5 bg-slate-800 rounded-full mt-1.5 overflow-hidden">
|
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-1.5 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||||
style={{ width: `${milestoneProgress}%` }}
|
style={{ width: `${milestoneProgress}%` }}
|
||||||
@@ -409,6 +413,19 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
|
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Total Value of Approved Project Budgets Widget */}
|
||||||
|
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-500 block">Approved Budget Value</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-400 mt-0.5 block font-mono">
|
||||||
|
{formatZAR(approvedBudgetTotal)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">
|
||||||
|
{approvedBudgetsCount > 0
|
||||||
|
? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}`
|
||||||
|
: 'Allocated Project Budget'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
getAllMilestones,
|
getAllMilestones,
|
||||||
getAllRisks,
|
getAllRisks,
|
||||||
getAllIndicators,
|
getAllIndicators,
|
||||||
|
getBudgets,
|
||||||
updateProjectStatus,
|
updateProjectStatus,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
getProjectTypeLabel,
|
getProjectTypeLabel,
|
||||||
getApprovalStatusBadge,
|
getApprovalStatusBadge,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatZAR,
|
||||||
INDUSTRY_SECTOR_OPTIONS,
|
INDUSTRY_SECTOR_OPTIONS,
|
||||||
PROJECT_TYPE_OPTIONS,
|
PROJECT_TYPE_OPTIONS,
|
||||||
APPROVAL_STATUS_OPTIONS,
|
APPROVAL_STATUS_OPTIONS,
|
||||||
@@ -201,6 +203,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
queryFn: () => getAllIndicators(),
|
queryFn: () => getAllIndicators(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allBudgets = [] } = useQuery({
|
||||||
|
queryKey: ['budgets'],
|
||||||
|
queryFn: () => getBudgets(1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
// Programme lookup map
|
// Programme lookup map
|
||||||
const programmeMap = useMemo(() => {
|
const programmeMap = useMemo(() => {
|
||||||
const map = new Map<string | number, Programme>();
|
const map = new Map<string | number, Programme>();
|
||||||
@@ -225,6 +232,26 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [allMilestones]);
|
}, [allMilestones]);
|
||||||
|
|
||||||
|
// Project Approved Budget Map
|
||||||
|
const approvedBudgetsByProject = useMemo(() => {
|
||||||
|
const map = new Map<string | number, number>();
|
||||||
|
allBudgets.forEach((b) => {
|
||||||
|
const pId = b.projectId;
|
||||||
|
if (pId !== undefined && pId !== null) {
|
||||||
|
if (
|
||||||
|
b.status === undefined ||
|
||||||
|
b.status === null ||
|
||||||
|
Number(b.status) === ApprovalStatus.Approved ||
|
||||||
|
Number(b.status) === 3
|
||||||
|
) {
|
||||||
|
const current = map.get(pId) || 0;
|
||||||
|
map.set(pId, current + (Number(b.amount) || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allBudgets]);
|
||||||
|
|
||||||
// Status Mutation
|
// Status Mutation
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
||||||
@@ -269,19 +296,21 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Export Columns Configuration
|
// Export Columns Configuration
|
||||||
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
|
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
|
||||||
{ header: 'Project ID', accessor: (p) => `#${p.id}` },
|
{ header: 'Project ID', key: 'id', format: (val) => `#${val}` },
|
||||||
{ header: 'Project Name', accessor: (p) => p.name || 'Untitled' },
|
{ header: 'Project Name', key: 'name', format: (val) => val || 'Untitled' },
|
||||||
{
|
{
|
||||||
header: 'Parent Programme',
|
header: 'Parent Programme',
|
||||||
accessor: (p) => programmeMap.get(p.programmeId)?.name || `Prog #${p.programmeId}`,
|
key: 'programmeId',
|
||||||
|
format: (val) => programmeMap.get(val)?.name || `Prog #${val}`,
|
||||||
},
|
},
|
||||||
{ header: 'Industry Sector', accessor: (p) => getIndustrySectorLabel(p.sector) },
|
{ header: 'Industry Sector', key: 'sector', format: (val) => getIndustrySectorLabel(val) },
|
||||||
{ header: 'Project Type', accessor: (p) => getProjectTypeLabel(p.projectType) },
|
{ header: 'Project Type', key: 'projectType', format: (val) => getProjectTypeLabel(val) },
|
||||||
{ header: 'Start Date', accessor: (p) => formatDate(p.startedAt) },
|
{ header: 'Start Date', key: 'startedAt', format: (val) => formatDate(val) },
|
||||||
{ header: 'Target End Date', accessor: (p) => formatDate(p.endedAt) },
|
{ header: 'Target End Date', key: 'endedAt', format: (val) => formatDate(val) },
|
||||||
{
|
{
|
||||||
header: 'Schedule Risk Alert',
|
header: 'Schedule Risk Alert',
|
||||||
accessor: (p) => {
|
key: 'id',
|
||||||
|
format: (_, p) => {
|
||||||
const stats = milestonesByProject.get(p.id);
|
const stats = milestonesByProject.get(p.id);
|
||||||
const risk = evaluateProjectScheduleRisk(p, stats);
|
const risk = evaluateProjectScheduleRisk(p, stats);
|
||||||
return risk.isAtRisk ? (risk.isOverdue ? `OVERDUE: ${risk.reason}` : `AT RISK: ${risk.reason}`) : 'ON TRACK';
|
return risk.isAtRisk ? (risk.isOverdue ? `OVERDUE: ${risk.reason}` : `AT RISK: ${risk.reason}`) : 'ON TRACK';
|
||||||
@@ -289,16 +318,25 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Milestone Progress',
|
header: 'Milestone Progress',
|
||||||
accessor: (p) => {
|
key: 'id',
|
||||||
|
format: (_, p) => {
|
||||||
const stats = milestonesByProject.get(p.id);
|
const stats = milestonesByProject.get(p.id);
|
||||||
if (!stats || stats.total === 0) return '0 / 0 (0%)';
|
if (!stats || stats.total === 0) return '0 / 0 (0%)';
|
||||||
const pct = Math.round((stats.verified / stats.total) * 100);
|
const pct = Math.round((stats.verified / stats.total) * 100);
|
||||||
return `${stats.verified} / ${stats.total} (${pct}%)`;
|
return `${stats.verified} / ${stats.total} (${pct}%)`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ header: 'Approval Status', accessor: (p) => getApprovalStatusBadge(p.status).label },
|
{
|
||||||
{ header: 'Created By', accessor: (p) => getUserEmail(p.createdBy) },
|
header: 'Approved Budget (ZAR)',
|
||||||
], [programmeMap, milestonesByProject]);
|
key: 'id',
|
||||||
|
format: (_, p) => {
|
||||||
|
const amt = approvedBudgetsByProject.get(p.id) || 0;
|
||||||
|
return amt > 0 ? formatZAR(amt) : 'R 0';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
|
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
|
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
|
||||||
|
|
||||||
// Table Sorting
|
// Table Sorting
|
||||||
const customExtractors = {
|
const customExtractors = {
|
||||||
@@ -439,14 +477,6 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
||||||
<ExportButton
|
|
||||||
title="Projects & Milestones Register"
|
|
||||||
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
|
||||||
filename="projects-milestones-register"
|
|
||||||
columns={projectExportColumns}
|
|
||||||
data={sortedProjects}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenCreateProject}
|
onClick={handleOpenCreateProject}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -596,6 +626,16 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Export Button */}
|
||||||
|
<ExportButton
|
||||||
|
title="Projects & Milestones Register"
|
||||||
|
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
||||||
|
filename="projects-milestones-register"
|
||||||
|
columns={projectExportColumns}
|
||||||
|
data={sortedProjects}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Single Refresh Button */}
|
{/* Single Refresh Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import autoTable from 'jspdf-autotable';
|
|||||||
|
|
||||||
export interface ExportColumn<T = any> {
|
export interface ExportColumn<T = any> {
|
||||||
header: string;
|
header: string;
|
||||||
key: keyof T | string;
|
key?: keyof T | string;
|
||||||
|
accessor?: (row: T) => any;
|
||||||
format?: (value: any, row: T) => string | number;
|
format?: (value: any, row: T) => string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +139,15 @@ export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
|
|||||||
const rows = data.map((item) => {
|
const rows = data.map((item) => {
|
||||||
const rowObj: Record<string, any> = {};
|
const rowObj: Record<string, any> = {};
|
||||||
columns.forEach((col) => {
|
columns.forEach((col) => {
|
||||||
const rawVal = (item as any)[col.key];
|
const rawVal = col.key ? (item as any)[col.key] : undefined;
|
||||||
const formatted = col.format ? col.format(rawVal, item) : (rawVal ?? '');
|
let formatted: any = '';
|
||||||
|
if (col.format) {
|
||||||
|
formatted = col.format(rawVal, item);
|
||||||
|
} else if (col.accessor) {
|
||||||
|
formatted = col.accessor(item);
|
||||||
|
} else {
|
||||||
|
formatted = rawVal ?? '';
|
||||||
|
}
|
||||||
rowObj[col.header] = formatted;
|
rowObj[col.header] = formatted;
|
||||||
});
|
});
|
||||||
return rowObj;
|
return rowObj;
|
||||||
@@ -151,8 +159,15 @@ export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
|
|||||||
const colWidths = columns.map((col) => {
|
const colWidths = columns.map((col) => {
|
||||||
const headerLen = col.header.length;
|
const headerLen = col.header.length;
|
||||||
const maxDataLen = data.reduce((max, item) => {
|
const maxDataLen = data.reduce((max, item) => {
|
||||||
const rawVal = (item as any)[col.key];
|
const rawVal = col.key ? (item as any)[col.key] : undefined;
|
||||||
const formatted = col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
|
let formatted = '';
|
||||||
|
if (col.format) {
|
||||||
|
formatted = String(col.format(rawVal, item));
|
||||||
|
} else if (col.accessor) {
|
||||||
|
formatted = String(col.accessor(item) ?? '');
|
||||||
|
} else {
|
||||||
|
formatted = String(rawVal ?? '');
|
||||||
|
}
|
||||||
return Math.max(max, formatted.length);
|
return Math.max(max, formatted.length);
|
||||||
}, headerLen);
|
}, headerLen);
|
||||||
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
|
||||||
@@ -191,8 +206,14 @@ export function exportToPdf<T>(options: ExportOptions<T>): void {
|
|||||||
const headers = [columns.map((c) => c.header)];
|
const headers = [columns.map((c) => c.header)];
|
||||||
const body = data.map((item) => {
|
const body = data.map((item) => {
|
||||||
return columns.map((col) => {
|
return columns.map((col) => {
|
||||||
const rawVal = (item as any)[col.key];
|
const rawVal = col.key ? (item as any)[col.key] : undefined;
|
||||||
return col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
|
if (col.format) {
|
||||||
|
return String(col.format(rawVal, item));
|
||||||
|
}
|
||||||
|
if (col.accessor) {
|
||||||
|
return String(col.accessor(item) ?? '');
|
||||||
|
}
|
||||||
|
return String(rawVal ?? '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user