fix: sync multi-day work hours accounting
Aggregate task work-hour rows into travel-cost calculations and monthly servicer reporting, and keep the frontend calendar aligned with the task work-hours source.
This commit is contained in:
@@ -24,6 +24,10 @@ function toDateKey(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
||||
const croatian = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\.?$/);
|
||||
if (croatian) {
|
||||
return `${croatian[3]}-${croatian[2]}-${croatian[1]}`;
|
||||
}
|
||||
const parsed = new Date(text);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
|
||||
@@ -77,6 +81,83 @@ function joinUnique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', ');
|
||||
}
|
||||
|
||||
function parseReportHours(value) {
|
||||
if (value == null || value === '') return 0;
|
||||
const numeric = Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
function parseReportDateTime(dateKey, value) {
|
||||
if (value == null || value === '') return null;
|
||||
if (value instanceof Date) {
|
||||
return new Date(value.getTime());
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) return null;
|
||||
const timeMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (dateKey && timeMatch) {
|
||||
const hours = String(timeMatch[1]).padStart(2, '0');
|
||||
const minutes = timeMatch[2];
|
||||
const seconds = timeMatch[3] || '00';
|
||||
return new Date(`${dateKey}T${hours}:${minutes}:${seconds}`);
|
||||
}
|
||||
const parsed = new Date(text);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function getTaskWorkHoursEntries(task, workOrdersById) {
|
||||
const tableData = task?.work_hours_table?.data ?? task?.work_hours_table ?? null;
|
||||
const rows = Array.isArray(tableData?.rows) ? tableData.rows : [];
|
||||
const workOrder = workOrdersById.get(String(task?.work_order || ''));
|
||||
const base = {
|
||||
title: task?.title || '',
|
||||
serial: getTaskCraneSerial(task) || '',
|
||||
client: getTaskCraneOwner(task) || '',
|
||||
location: workOrder?.location || '',
|
||||
workOrderLabel: task?.work_order_label || '',
|
||||
};
|
||||
const entries = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const dateKey = toDateKey(row.date) || toDateKey(task?.scheduled_date);
|
||||
if (!dateKey) continue;
|
||||
const startAt = parseReportDateTime(dateKey, row.work_time_from || row.travel_time_from || '');
|
||||
const endAt = parseReportDateTime(dateKey, row.work_time_to || row.travel_time_to || '');
|
||||
entries.push({
|
||||
...base,
|
||||
dateKey,
|
||||
startAt,
|
||||
endAt,
|
||||
totalHours: parseReportHours(row.work_hours) + parseReportHours(row.travel_hours),
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const travelStart = workOrder?.travel_start_at;
|
||||
const travelEnd = workOrder?.travel_end_at;
|
||||
if (travelStart && travelEnd) {
|
||||
const startAt = parseReportDateTime(null, travelStart);
|
||||
const endAt = parseReportDateTime(null, travelEnd);
|
||||
const dateKey = toDateKey(task?.scheduled_date) || toDateKey(travelStart);
|
||||
const totalHours = startAt && endAt ? Math.max(0, (endAt.getTime() - startAt.getTime()) / 3600000) : 0;
|
||||
if (dateKey) {
|
||||
entries.push({
|
||||
...base,
|
||||
dateKey,
|
||||
startAt,
|
||||
endAt,
|
||||
totalHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
|
||||
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||
@@ -196,6 +277,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
};
|
||||
}, [reportOpen, reportType, viewYear, viewMonth]);
|
||||
|
||||
const reportTaskEntriesByDate = useMemo(() => {
|
||||
const map = {};
|
||||
for (const task of tasks) {
|
||||
for (const entry of getTaskWorkHoursEntries(task, workOrdersById)) {
|
||||
if (!map[entry.dateKey]) map[entry.dateKey] = [];
|
||||
map[entry.dateKey].push(entry);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tasks, workOrdersById]);
|
||||
|
||||
const servicerRows = useMemo(() => {
|
||||
if (reportType !== 'servicer') return [];
|
||||
const rows = [];
|
||||
@@ -204,42 +296,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
||||
const dayTasks = tasksByDate[key] ?? [];
|
||||
const dayEntries = reportTaskEntriesByDate[key] ?? [];
|
||||
const manualEntry = manualEntriesByDate[key];
|
||||
|
||||
if (dayTasks.length > 0) {
|
||||
if (dayEntries.length > 0) {
|
||||
const titles = [];
|
||||
const serials = [];
|
||||
const clients = [];
|
||||
const locations = [];
|
||||
const workOrderLabels = [];
|
||||
const workStartTimes = [];
|
||||
const workEndTimes = [];
|
||||
const countedWorkOrders = new Set();
|
||||
const startTimes = [];
|
||||
const endTimes = [];
|
||||
let totalHours = 0;
|
||||
|
||||
for (const task of dayTasks) {
|
||||
if (task?.title) titles.push(task.title);
|
||||
serials.push(getTaskCraneSerial(task));
|
||||
clients.push(getTaskCraneOwner(task));
|
||||
if (task?.work_order_label) workOrderLabels.push(task.work_order_label);
|
||||
|
||||
const workOrder = workOrdersById.get(String(task.work_order || ''));
|
||||
if (workOrder?.location) locations.push(workOrder.location);
|
||||
if (
|
||||
workOrder?.travel_start_at
|
||||
&& workOrder?.travel_end_at
|
||||
&& !countedWorkOrders.has(String(workOrder.id))
|
||||
) {
|
||||
const startAt = new Date(workOrder.travel_start_at);
|
||||
const endAt = new Date(workOrder.travel_end_at);
|
||||
if (!Number.isNaN(startAt.getTime()) && !Number.isNaN(endAt.getTime()) && endAt > startAt) {
|
||||
countedWorkOrders.add(String(workOrder.id));
|
||||
workStartTimes.push(startAt);
|
||||
workEndTimes.push(endAt);
|
||||
totalHours += (endAt.getTime() - startAt.getTime()) / 3600000;
|
||||
}
|
||||
}
|
||||
for (const entry of dayEntries) {
|
||||
if (entry.title) titles.push(entry.title);
|
||||
if (entry.serial) serials.push(entry.serial);
|
||||
if (entry.client) clients.push(entry.client);
|
||||
if (entry.location) locations.push(entry.location);
|
||||
if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel);
|
||||
if (entry.startAt) startTimes.push(entry.startAt);
|
||||
if (entry.endAt) endTimes.push(entry.endAt);
|
||||
totalHours += Number(entry.totalHours || 0);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
@@ -252,10 +330,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
joinUnique(serials) || '-',
|
||||
joinUnique(clients) || '-',
|
||||
joinUnique(locations) || '-',
|
||||
workStartTimes.length ? formatTime(new Date(Math.min(...workStartTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
||||
workEndTimes.length ? formatTime(new Date(Math.max(...workEndTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
||||
totalHours > 0 ? formatHourValue(Math.min(totalHours, 8), '0') : '-',
|
||||
totalHours > 8 ? formatHourValue(totalHours - 8, '0') : '0',
|
||||
startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-',
|
||||
endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-',
|
||||
'8',
|
||||
formatHourValue(totalHours, '0'),
|
||||
joinUnique(workOrderLabels) || '-',
|
||||
],
|
||||
});
|
||||
@@ -292,7 +370,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [manualEntriesByDate, reportType, tasksByDate, viewMonth, viewYear, workOrdersById]);
|
||||
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
|
||||
|
||||
const costsRows = useMemo(() => {
|
||||
if (reportType !== 'costs') return [];
|
||||
|
||||
Reference in New Issue
Block a user