Fixed timeline height
Moved export and fixed data output Added project budget amount widget to project listing
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
getAllMilestones,
|
||||
getAllRisks,
|
||||
getAllIndicators,
|
||||
getBudgets,
|
||||
updateProjectStatus,
|
||||
} from '../services/api';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
getProjectTypeLabel,
|
||||
getApprovalStatusBadge,
|
||||
formatDate,
|
||||
formatZAR,
|
||||
INDUSTRY_SECTOR_OPTIONS,
|
||||
PROJECT_TYPE_OPTIONS,
|
||||
APPROVAL_STATUS_OPTIONS,
|
||||
@@ -201,6 +203,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const { data: allBudgets = [] } = useQuery({
|
||||
queryKey: ['budgets'],
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
});
|
||||
|
||||
// Programme lookup map
|
||||
const programmeMap = useMemo(() => {
|
||||
const map = new Map<string | number, Programme>();
|
||||
@@ -225,6 +232,26 @@ export const ProjectsPage: React.FC = () => {
|
||||
return map;
|
||||
}, [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
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
|
||||
@@ -269,19 +296,21 @@ export const ProjectsPage: React.FC = () => {
|
||||
|
||||
// Export Columns Configuration
|
||||
const projectExportColumns: ExportColumn<Project>[] = useMemo(() => [
|
||||
{ header: 'Project ID', accessor: (p) => `#${p.id}` },
|
||||
{ header: 'Project Name', accessor: (p) => p.name || 'Untitled' },
|
||||
{ header: 'Project ID', key: 'id', format: (val) => `#${val}` },
|
||||
{ header: 'Project Name', key: 'name', format: (val) => val || 'Untitled' },
|
||||
{
|
||||
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: 'Project Type', accessor: (p) => getProjectTypeLabel(p.projectType) },
|
||||
{ header: 'Start Date', accessor: (p) => formatDate(p.startedAt) },
|
||||
{ header: 'Target End Date', accessor: (p) => formatDate(p.endedAt) },
|
||||
{ header: 'Industry Sector', key: 'sector', format: (val) => getIndustrySectorLabel(val) },
|
||||
{ header: 'Project Type', key: 'projectType', format: (val) => getProjectTypeLabel(val) },
|
||||
{ header: 'Start Date', key: 'startedAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Target End Date', key: 'endedAt', format: (val) => formatDate(val) },
|
||||
{
|
||||
header: 'Schedule Risk Alert',
|
||||
accessor: (p) => {
|
||||
key: 'id',
|
||||
format: (_, p) => {
|
||||
const stats = milestonesByProject.get(p.id);
|
||||
const risk = evaluateProjectScheduleRisk(p, stats);
|
||||
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',
|
||||
accessor: (p) => {
|
||||
key: 'id',
|
||||
format: (_, p) => {
|
||||
const stats = milestonesByProject.get(p.id);
|
||||
if (!stats || stats.total === 0) return '0 / 0 (0%)';
|
||||
const pct = Math.round((stats.verified / stats.total) * 100);
|
||||
return `${stats.verified} / ${stats.total} (${pct}%)`;
|
||||
},
|
||||
},
|
||||
{ header: 'Approval Status', accessor: (p) => getApprovalStatusBadge(p.status).label },
|
||||
{ header: 'Created By', accessor: (p) => getUserEmail(p.createdBy) },
|
||||
], [programmeMap, milestonesByProject]);
|
||||
{
|
||||
header: 'Approved Budget (ZAR)',
|
||||
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
|
||||
const customExtractors = {
|
||||
@@ -439,14 +477,6 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
@@ -596,6 +626,16 @@ export const ProjectsPage: React.FC = () => {
|
||||
</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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user