diff --git a/src/components/plan/PlanResourceModal.tsx b/src/components/plan/PlanResourceModal.tsx index b8dddf5..80131c3 100644 --- a/src/components/plan/PlanResourceModal.tsx +++ b/src/components/plan/PlanResourceModal.tsx @@ -5,8 +5,15 @@ import toast from 'react-hot-toast'; import { ExclamationTriangleIcon, ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; import { Modal, Button, Select } from '@/components/ui'; import { useProjectsStore, useCompanyStore } from '@/hooks'; +import { usePlanStore } from '@/hooks/usePlanStore'; import { bcClient } from '@/services/bc/bcClient'; import { getBCResourceUrl, getBCJobUrl } from '@/utils/bcUrls'; +import { + buildUOMConversionMap, + convertToHours, + convertFromHours, + type UOMConversionMap, +} from '@/utils'; import { ResourceWorkload } from './ResourceWorkload'; import type { SelectOption, BCResource } from '@/types'; import { format, getWeek, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns'; @@ -34,8 +41,10 @@ export function PlanResourceModal({ }: PlanResourceModalProps) { const { projects, fetchProjects } = useProjectsStore(); const { selectedCompany } = useCompanyStore(); + const cachedUomMap = usePlanStore((s) => s.uomConversionMap); const [resources, setResources] = useState([]); + const [uomMap, setUomMap] = useState(new Map()); const [resourceId, setResourceId] = useState(''); const [taskId, setTaskId] = useState(''); const [dayHours, setDayHours] = useState>({}); @@ -64,12 +73,25 @@ export function PlanResourceModal({ [projects, projectNumber] ); - // Check if BC extension is installed + // Check if BC extension is installed and load UOM data useEffect(() => { if (isOpen) { bcClient.isExtensionInstalled().then(setExtensionInstalled); + // Use cached UOM map from plan store if available, otherwise fetch + if (cachedUomMap.size > 0) { + setUomMap(cachedUomMap); + } else { + bcClient + .getResourceUnitsOfMeasure() + .then((resourceUOMs) => { + setUomMap(buildUOMConversionMap(resourceUOMs)); + }) + .catch(() => { + setUomMap(new Map()); + }); + } } - }, [isOpen]); + }, [isOpen, cachedUomMap]); // Fetch resources and projects when modal opens useEffect(() => { @@ -143,12 +165,14 @@ export function PlanResourceModal({ newHours[dateKey] = ''; }); - // Fill in existing values + // Fill in existing values (convert base unit to hours for display) for (const line of existingLines) { const dateKey = line.planningDate; + // Convert from resource's base unit to hours + const hoursValue = convertToHours(resourceId, line.quantity, uomMap); // If there are multiple lines for the same day, sum them const existingVal = parseFloat(newHours[dateKey] || '0'); - newHours[dateKey] = (existingVal + line.quantity).toString(); + newHours[dateKey] = (existingVal + hoursValue).toString(); // Track id and etag for updates (last one wins if multiple) newLinesByDate[dateKey] = { id: line.id, etag: line['@odata.etag'] || '' }; } @@ -172,7 +196,7 @@ export function PlanResourceModal({ } finally { setIsLoadingExisting(false); } - }, [resourceId, taskId, currentProject, projectNumber, weekStart, weekEnd, weekDays]); + }, [resourceId, taskId, currentProject, projectNumber, weekStart, weekEnd, weekDays, uomMap]); // Trigger fetch when both resource and task are selected useEffect(() => { @@ -282,26 +306,28 @@ export function PlanResourceModal({ deleted++; } - // Update existing lines + // Update existing lines (convert hours to resource's base unit) for (const item of toUpdate) { + const quantityInBaseUnit = convertFromHours(resourceId, item.hours, uomMap); await bcClient.updateJobPlanningLine( item.id, { - quantity: item.hours, + quantity: quantityInBaseUnit, }, item.etag ); updated++; } - // Create new lines + // Create new lines (convert hours to resource's base unit) for (const item of toCreate) { + const quantityInBaseUnit = convertFromHours(resourceId, item.hours, uomMap); await bcClient.createJobPlanningLine({ jobNo: projectNumber, jobTaskNo: task.code, resourceNo: resourceId, planningDate: item.date, - quantity: item.hours, + quantity: quantityInBaseUnit, }); created++; } diff --git a/src/components/projects/ProjectCharts.tsx b/src/components/projects/ProjectCharts.tsx index ed90dc4..f22979c 100644 --- a/src/components/projects/ProjectCharts.tsx +++ b/src/components/projects/ProjectCharts.tsx @@ -248,7 +248,8 @@ function getISOWeek(date: Date): string { } /** - * Generate display data for the chart with all weeks filled in + * Generate display data for the chart with all weeks filled in. + * Extends the window forward to include future weeks with planned hours. */ function generateWeeklyDisplayData( data: WeeklyDataPoint[], @@ -273,8 +274,27 @@ function generateWeeklyDisplayData( }); } - // Calculate the end week (current week minus offset) - const endWeekDate = new Date(currentWeekStart); + // Find the latest week in the data that has planned or actual hours + // This extends the chart window forward to show future planned hours + let effectiveEnd = new Date(currentWeekStart); + const maxFutureWeeks = 12; // Cap at 12 weeks ahead to keep chart readable + for (const d of data) { + if (d.week > currentWeekStr && d.plannedHours > 0) { + // Walk forward from current week to find this data week + const candidate = new Date(currentWeekStart); + let weeksAhead = 0; + while (getISOWeek(candidate) < d.week && weeksAhead < maxFutureWeeks) { + candidate.setDate(candidate.getDate() + 7); + weeksAhead++; + } + if (candidate > effectiveEnd) { + effectiveEnd = new Date(candidate); + } + } + } + + // Calculate the end week (effective end minus offset) + const endWeekDate = new Date(effectiveEnd); endWeekDate.setDate(endWeekDate.getDate() - offsetWeeks * 7); // Generate weeks array @@ -448,19 +468,19 @@ function WeeklyBarChart({ data, offsetWeeks }: WeeklyBarChartProps) { {point.plannedHours > 0 && (
- Planned: {point.plannedHours.toFixed(1)}h + Planned: {point.plannedHours.toFixed(2)}h
)} {point.approvedHours > 0 && (
- Approved: {point.approvedHours.toFixed(1)}h + Approved: {point.approvedHours.toFixed(2)}h
)} {point.pendingHours > 0 && (
- Pending: {point.pendingHours.toFixed(1)}h + Pending: {point.pendingHours.toFixed(2)}h
)} {point.hours === 0 && point.plannedHours === 0 && ( @@ -636,10 +656,12 @@ function ProgressLineChart({ return Math.ceil(max / 1000) * 1000; }, [displayDataWithCost, budgetCost]); - // Generate Y-axis labels in £ + // Generate Y-axis labels in £ (target ~5 labels for readability) const yAxisLabels = useMemo(() => { const labels = []; - const step = maxCost <= 1000 ? 200 : maxCost <= 2000 ? 500 : 1000; + const rawStep = maxCost / 5; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const step = Math.ceil(rawStep / magnitude) * magnitude; for (let i = 0; i <= maxCost; i += step) { labels.push(i); } @@ -808,7 +830,7 @@ function ProgressLineChart({
- {displayDataWithCost[hoveredIndex].cumulative.toFixed(1)} hours + {displayDataWithCost[hoveredIndex].cumulative.toFixed(2)} hours
{avgCostRate !== null && (
@@ -872,7 +894,7 @@ function ProgressLineChart({ })}
- {displayDataWithCost[hoveredIndex].cumulative.toFixed(1)} hours + {displayDataWithCost[hoveredIndex].cumulative.toFixed(2)} hours
{displayDataWithCost[hoveredIndex].isCurrentWeek && (
This week
diff --git a/src/components/projects/ProjectKPICards.tsx b/src/components/projects/ProjectKPICards.tsx index a510756..94287c1 100644 --- a/src/components/projects/ProjectKPICards.tsx +++ b/src/components/projects/ProjectKPICards.tsx @@ -70,7 +70,7 @@ function InfoTooltip({ function formatHoursWithDays(hours: number, hoursPerDay: number): string { const days = hours / hoursPerDay; if (hours === 0) return '0h (0d)'; - return `${hours.toFixed(1)}h (${days.toFixed(1)}d)`; + return `${hours.toFixed(2)}h (${days.toFixed(2)}d)`; } // Format currency using the company's currency code from BC @@ -145,7 +145,7 @@ export function ProjectKPICards() { color: hoursRemaining < 0 ? 'text-red-400' : 'text-blue-400', tooltip: { title: 'Time Budgeted', - description: `Budgeted hours from Job Planning Lines. Only includes Resource lines where lineType is "Budget" or "Both Budget and Billable". Days = hours ÷ ${hoursPerDay}.`, + description: `Contracted budget from Billable Job Planning Lines. Only includes Resource lines where lineType is "Billable" or "Both Budget and Billable". Days = hours ÷ ${hoursPerDay}.`, source: 'BC API: /jobPlanningLines → quantity', }, }, diff --git a/src/services/bc/projectDetailsService.ts b/src/services/bc/projectDetailsService.ts index f2b232c..ab6cdc5 100644 --- a/src/services/bc/projectDetailsService.ts +++ b/src/services/bc/projectDetailsService.ts @@ -431,18 +431,19 @@ export const projectDetailsService = { lineType === 'Both Budget and Billable' || lineType === 'Both_x0020_Budget_x0020_and_x0020_Billable'; - // Hours Planned: sum quantity from Resource Budget lines only (hours only apply to resources) - // Convert quantity to hours using unit of measure conversion factor + // Hours Planned: sum quantity from Billable Resource lines (the contracted project budget) + // Budget lines are weekly allocations from the Plan screen and are not included here const resourceLines = planningLines.filter( (line: BCJobPlanningLine) => line.type === 'Resource' ); - hoursPlanned = resourceLines - .filter((line: BCJobPlanningLine) => isBudgetLine(line.lineType)) - .reduce( - (sum: number, line: BCJobPlanningLine) => - sum + convertToHours(line.number, line.quantity, uomConversionMap), - 0 - ); + const billableResourceLines = resourceLines.filter((line: BCJobPlanningLine) => + isBillableLine(line.lineType) + ); + hoursPlanned = billableResourceLines.reduce( + (sum: number, line: BCJobPlanningLine) => + sum + convertToHours(line.number, line.quantity, uomConversionMap), + 0 + ); // Extract unit price per resource from planning lines as fallback // (only if Resource Card doesn't have unitPrice set) @@ -456,31 +457,31 @@ export const projectDetailsService = { } } - // Budget Cost: sum totalCost from ALL Budget lines with breakdown by type - const budgetLines = planningLines.filter((line: BCJobPlanningLine) => - isBudgetLine(line.lineType) + // Billable lines represent the contracted project budget + // Budget lines are weekly allocations from the Plan screen and are not included in budget/price totals + const billableLines = planningLines.filter((line: BCJobPlanningLine) => + isBillableLine(line.lineType) ); - budgetCost = budgetLines.reduce( - (sum: number, line: BCJobPlanningLine) => sum + line.totalCost, + + // Budget Cost: sum totalPrice from Billable lines (contracted budget) with breakdown by type + budgetCost = billableLines.reduce( + (sum: number, line: BCJobPlanningLine) => sum + line.totalPrice, 0 ); budgetCostBreakdown = { - resource: budgetLines + resource: billableLines .filter((line: BCJobPlanningLine) => line.type === 'Resource') - .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalCost, 0), - item: budgetLines + .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalPrice, 0), + item: billableLines .filter((line: BCJobPlanningLine) => line.type === 'Item') - .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalCost, 0), - glAccount: budgetLines + .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalPrice, 0), + glAccount: billableLines .filter((line: BCJobPlanningLine) => line.type === 'G/L Account') - .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalCost, 0), + .reduce((sum: number, line: BCJobPlanningLine) => sum + line.totalPrice, 0), total: budgetCost, }; - // Billable Price: sum totalPrice from ALL Billable lines with breakdown by type - const billableLines = planningLines.filter((line: BCJobPlanningLine) => - isBillableLine(line.lineType) - ); + // Billable Price: sum totalPrice from Billable lines with breakdown by type billablePrice = billableLines.reduce( (sum: number, line: BCJobPlanningLine) => sum + line.totalPrice, 0 diff --git a/src/services/bc/projectService.ts b/src/services/bc/projectService.ts index 4f219fc..ce9db9d 100644 --- a/src/services/bc/projectService.ts +++ b/src/services/bc/projectService.ts @@ -8,6 +8,7 @@ import type { BCTimeSheetLine, BCJobPlanningLine, } from '@/types'; +import { buildUOMConversionMap, convertToHours } from '@/utils'; // Color palette for projects const PROJECT_COLORS = [ @@ -209,15 +210,30 @@ export const projectService = { async getProjectBudgets(projectCodes: string[]): Promise> { const projectBudgets = new Map(); + // Fetch UOM conversion map once for all projects + const resourceUOMs = await bcClient.getResourceUnitsOfMeasure(); + const uomConversionMap = buildUOMConversionMap(resourceUOMs); + + const isBillableLine = (lineType: string) => + lineType === 'Billable' || + lineType === 'Both Budget and Billable' || + lineType === 'Both_x0020_Budget_x0020_and_x0020_Billable'; + // Fetch budget for each project in parallel const budgetPromises = projectCodes.map(async (projectCode) => { try { const planningLines = await bcClient.getJobPlanningLines(projectCode); - // Sum quantity for Resource type lines (hours-based budgets) + // Sum hours for Billable Resource lines (contracted project budget) const budgetHours = planningLines - .filter((line: BCJobPlanningLine) => line.type === 'Resource') - .reduce((sum: number, line: BCJobPlanningLine) => sum + line.quantity, 0); + .filter( + (line: BCJobPlanningLine) => line.type === 'Resource' && isBillableLine(line.lineType) + ) + .reduce( + (sum: number, line: BCJobPlanningLine) => + sum + convertToHours(line.number, line.quantity, uomConversionMap), + 0 + ); if (budgetHours > 0) { projectBudgets.set(projectCode, budgetHours);