fix: sync multi-day work hours accounting
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

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:
mariomitte
2026-08-08 01:32:17 +02:00
parent 38f1786469
commit 181cab1f45
5 changed files with 362 additions and 87 deletions

View File

@@ -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 [];