Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions src/components/plan/PlanResourceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<BCResource[]>([]);
const [uomMap, setUomMap] = useState<UOMConversionMap>(new Map());
const [resourceId, setResourceId] = useState('');
const [taskId, setTaskId] = useState('');
const [dayHours, setDayHours] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -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(() => {
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If getResourceUnitsOfMeasure() fails, the code silently falls back to an empty UOM map. In that case convertToHours/convertFromHours will behave like identity functions, potentially reintroducing the DAY/HOUR UOM bug and writing incorrect quantities back to BC. Consider surfacing an error (e.g., toast) and preventing save until UOM data is available (or retrying fetch on submit).

Suggested change
.catch(() => {
.catch((error) => {
console.error('Failed to load resource units of measure', error);
toast.error('Failed to load resource units of measure');

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low risk since empty map means identity conversion (hours pass through as-is, which is correct for HOUR-based resources). Will consider adding error surfacing in a follow-up.

setUomMap(new Map());
});
}
}
}, [isOpen]);
}, [isOpen, cachedUomMap]);

// Fetch resources and projects when modal opens
useEffect(() => {
Expand Down Expand Up @@ -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'] || '' };
}
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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++;
}
Expand Down
42 changes: 32 additions & 10 deletions src/components/projects/ProjectCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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
Expand Down Expand Up @@ -448,19 +468,19 @@ function WeeklyBarChart({ data, offsetWeeks }: WeeklyBarChartProps) {
{point.plannedHours > 0 && (
<div className="flex items-center gap-2 text-gray-400">
<span className="inline-block h-2 w-2 rounded-sm bg-gray-500" />
Planned: {point.plannedHours.toFixed(1)}h
Planned: {point.plannedHours.toFixed(2)}h
</div>
)}
{point.approvedHours > 0 && (
<div className="flex items-center gap-2 text-gray-400">
<span className="bg-thyme-500 inline-block h-2 w-2 rounded-sm" />
Approved: {point.approvedHours.toFixed(1)}h
Approved: {point.approvedHours.toFixed(2)}h
</div>
)}
{point.pendingHours > 0 && (
<div className="flex items-center gap-2 text-gray-400">
<span className="inline-block h-2 w-2 rounded-sm bg-amber-500" />
Pending: {point.pendingHours.toFixed(1)}h
Pending: {point.pendingHours.toFixed(2)}h
</div>
)}
{point.hours === 0 && point.plannedHours === 0 && (
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -808,7 +830,7 @@ function ProgressLineChart({
</div>
<div className="border-dark-500 mt-1 border-t pt-1">
<div className="text-gray-400">
{displayDataWithCost[hoveredIndex].cumulative.toFixed(1)} hours
{displayDataWithCost[hoveredIndex].cumulative.toFixed(2)} hours
</div>
{avgCostRate !== null && (
<div className="text-thyme-400">
Expand Down Expand Up @@ -872,7 +894,7 @@ function ProgressLineChart({
})}
</div>
<div className="text-gray-400">
{displayDataWithCost[hoveredIndex].cumulative.toFixed(1)} hours
{displayDataWithCost[hoveredIndex].cumulative.toFixed(2)} hours
</div>
{displayDataWithCost[hoveredIndex].isCurrentWeek && (
<div className="text-thyme-400">This week</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/projects/ProjectKPICards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
},
},
Expand Down
49 changes: 25 additions & 24 deletions src/services/bc/projectDetailsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
);
Comment on lines +460 to 470
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

budgetCost is now computed from billableLines by summing line.totalPrice. A few lines later, billablePrice is also computed from the same billableLines by summing line.totalPrice, which makes budgetCost and billablePrice identical and breaks the intended cost-vs-price distinction (and the existing UI/tooltips that say Budget Cost comes from totalCost). If Budget Cost is meant to remain an internal cost budget, this should sum totalCost (and breakdowns should use totalCost) while billablePrice continues to use totalPrice.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. The user confirmed Budget Cost should show the contracted amount (totalPrice from Billable lines), not internal cost. For their workflow the project budget IS the contracted price.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure about this one ... needs further confirmation / investigation I think.

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
Expand Down
22 changes: 19 additions & 3 deletions src/services/bc/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
BCTimeSheetLine,
BCJobPlanningLine,
} from '@/types';
import { buildUOMConversionMap, convertToHours } from '@/utils';

// Color palette for projects
const PROJECT_COLORS = [
Expand Down Expand Up @@ -209,15 +210,30 @@ export const projectService = {
async getProjectBudgets(projectCodes: string[]): Promise<Map<string, number>> {
const projectBudgets = new Map<string, number>();

// 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);
Expand Down