MEGAINFRA-WEBAPP/src/lib/timesheet-grid-parse.ts
DEls 1b9779f47e feat(timesheets): enhance import functionality to support XLSX and CSV formats
- Updated the import route to accept both long-format CSV and Hills-style wide-format XLSX files.
- Implemented a new parsing mechanism that detects file type and processes accordingly.
- Enhanced error handling to provide more informative messages during file parsing failures.
- Modified the response structure to include total rows processed and detailed error reporting.
- Updated the UI components to reflect changes in file selection and user instructions for importing timesheets.
2026-08-25 18:38:02 +02:00

328 lines
10 KiB
TypeScript

/**
* Pure parsing helpers for the Hills-style wide-format timesheet XLSX.
*
* Sheet layout (row indices are 0-based):
*
* Row 0 : anchor date in col 0 (Excel serial). Day-of-week names
* in cols 3..33 ("Tue", "Wed", ...). We use the names only
* for validation; the dates row below is the source of
* truth for day-of-month.
* Row 2 : dates - day-of-month numbers in cols 3..33. They wrap
* (e.g. 21..31 then 1..20) when the pay period crosses a
* month boundary. The wrap is detected, not assumed.
* Row N : site name in col 0, col 1 empty.
* Row N+k : employee rows - name in col 0, employee_no in col 1
* ("MCW\d+"), daily hours in cols 3..33.
*
* Output: long-format rows matching the existing `timesheets` shape
* (one row per employee per non-zero day). No PocketBase changes
* required to consume the result.
*/
export interface ParsedTimesheetRow {
/** matches employees.employee_no, e.g. "MCW058" */
employeeNo: string;
/** human name from col 0 (informational, not stored) */
name: string;
/** YYYY-MM-DD */
date: string;
hoursWorked: number;
}
export interface ParsedTimesheet {
siteName: string | null;
/** YYYY-MM-DD of the first non-null date column */
periodStart: string | null;
/** YYYY-MM-DD of the last non-null date column */
periodEnd: string | null;
/** Sheet name as it appeared in the workbook (e.g. "August 2026") */
sheetName: string;
rows: ParsedTimesheetRow[];
/** Soft errors (e.g. sheet name didn't match a month) - caller decides what to do. */
errors: string[];
/** Number of employees that had at least one day logged. */
employeeRowsImported: number;
/** Total non-zero day cells read across all employee rows. */
totalDayCells: number;
}
const MONTH_ALIASES: Record<string, number> = {
january: 0, jan: 0,
february: 1, feb: 1,
march: 2, mar: 2,
april: 3, apr: 3,
may: 4,
june: 5, jun: 5,
july: 6, jul: 6,
august: 7, aug: 7,
september: 8, sep: 8, sept: 8,
october: 9, oct: 9,
november: 10, nov: 10,
december: 11, dec: 11,
};
const DAY_ABBR = new Set(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]);
const DAY_FULL = new Set([
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
]);
/**
* Parse a Hills-style wide-format timesheet sheet.
*
* `rows` is the raw 2-D array from `XLSX.utils.sheet_to_json(sh, { header: 1, defval: null })`.
* `sheetName` is the workbook sheet name (used to determine the primary month).
*/
export function parseHillsTimesheet(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
sheetName: string,
): ParsedTimesheet {
const errors: string[] = [];
const monthInfo = parseMonthFromSheetName(sheetName);
if (!monthInfo) {
errors.push(
`Could not parse month/year from sheet name "${sheetName}". ` +
`Expected something like "August 2026".`,
);
}
const dayNameRowIdx = findRowMatching(rows, (r) =>
countAtColumns(r, 3, 33, isDayName) >= 25,
);
if (dayNameRowIdx === -1) {
errors.push("Could not find the day-of-week header row (25+ day names).");
}
const datesRowIdx = findRowMatching(rows, (r) =>
countAtColumns(r, 3, 33, (v) => {
const n = toNumber(v);
return n !== null && n >= 1 && n <= 31;
}) >= 25,
);
if (datesRowIdx === -1) {
errors.push("Could not find the dates row (25+ day-of-month values).");
}
const siteRowIdx = findSiteRow(rows);
const siteName =
siteRowIdx >= 0 && typeof rows[siteRowIdx]?.[0] === "string"
? (rows[siteRowIdx][0] as string).trim()
: null;
let columnDates: (string | null)[] = [];
let periodStart: string | null = null;
let periodEnd: string | null = null;
if (dayNameRowIdx !== -1 && datesRowIdx !== -1 && monthInfo) {
columnDates = buildColumnDates(rows[datesRowIdx], monthInfo);
const mismatches = validateDayNames(rows[dayNameRowIdx], columnDates);
if (mismatches.length > 0) {
errors.push(
`Day-of-week headers don't match computed dates for ${mismatches.length} column(s); ` +
`trusting the typed day-of-month numbers.`,
);
}
const valid = columnDates.filter((d): d is string => d !== null);
if (valid.length > 0) {
periodStart = valid[0];
periodEnd = valid[valid.length - 1];
}
}
const employeeRows = findEmployeeRows(rows);
const parsedRows: ParsedTimesheetRow[] = [];
let totalDayCells = 0;
for (const { idx, name, employeeNo } of employeeRows) {
for (let col = 3; col < rows[idx].length && col < columnDates.length; col++) {
const date = columnDates[col];
if (!date) continue;
const hours = toNumber(rows[idx][col]);
if (hours === null || hours <= 0) continue;
parsedRows.push({ employeeNo, name, date, hoursWorked: hours });
totalDayCells++;
}
}
return {
siteName,
periodStart,
periodEnd,
sheetName,
rows: parsedRows,
errors,
employeeRowsImported: employeeRows.length,
totalDayCells,
};
}
/**
* Parse a sheet name like "August 2026" into { year, month } where
* month is 0-indexed (0 = January).
*/
export function parseMonthFromSheetName(
name: string,
): { year: number; month: number } | null {
const m = name.toLowerCase().match(/([a-z]+)\s+(\d{4})/);
if (!m) return null;
const month = MONTH_ALIASES[m[1]];
const year = parseInt(m[2], 10);
if (month === undefined || !Number.isFinite(year) || year < 1900 || year > 2200)
return null;
return { year, month };
}
function isDayName(v: unknown): boolean {
if (typeof v !== "string") return false;
const t = v.trim();
return DAY_ABBR.has(t) || DAY_FULL.has(t);
}
function countAtColumns(
row: ReadonlyArray<unknown>,
from: number,
to: number,
pred: (v: unknown) => boolean,
): number {
let n = 0;
const upper = Math.min(to, row.length);
for (let i = from; i <= upper; i++) {
if (pred(row[i])) n++;
}
return n;
}
function findRowMatching(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
pred: (r: ReadonlyArray<unknown>) => boolean,
): number {
const upper = Math.min(rows.length, 12); // headers live near the top
for (let i = 0; i < upper; i++) {
if (pred(rows[i] || [])) return i;
}
return -1;
}
function findSiteRow(rows: ReadonlyArray<ReadonlyArray<unknown>>): number {
for (let i = 0; i < rows.length; i++) {
const r = rows[i] || [];
const c0 = r[0];
const c1 = r[1];
if (typeof c0 !== "string" || !c0.trim()) continue;
// Must NOT look like an employee row
if (typeof c1 === "string" && /^MCW\d/i.test(c1)) continue;
// Must have non-trivial text in col 0 and empty col 1
return i;
}
return -1;
}
function findEmployeeRows(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
): { idx: number; name: string; employeeNo: string }[] {
const out: { idx: number; name: string; employeeNo: string }[] = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i] || [];
const c0 = r[0];
const c1 = r[1];
if (typeof c0 !== "string" || !c0.trim()) continue;
if (typeof c1 !== "string" || !/^MCW\d/i.test(c1)) continue;
out.push({ idx: i, name: c0.trim(), employeeNo: c1.trim().toUpperCase() });
}
return out;
}
/**
* Build a column-to-date mapping for the day columns. The day-of-month
* values come from the typed dates row. When the values wrap (e.g.
* 21..31 then 1..20), the larger chunk is treated as the sheet's
* primary month and the smaller chunk as the previous month.
*/
export function buildColumnDates(
datesRow: ReadonlyArray<unknown>,
monthInfo: { year: number; month: number },
): (string | null)[] {
const dayNums: (number | null)[] = [];
for (let i = 0; i < datesRow.length; i++) {
const n = toNumber(datesRow[i]);
if (n !== null && n >= 1 && n <= 31) dayNums.push(n);
else dayNums.push(null);
}
// Find the first wrap (day-of-month decreases)
let wrapIdx = -1;
for (let i = 1; i < dayNums.length; i++) {
const a = dayNums[i - 1];
const b = dayNums[i];
if (a !== null && b !== null && b < a) {
wrapIdx = i;
break;
}
}
let primaryFrom = 0;
let primaryTo = dayNums.length;
let overflowFrom = -1;
let overflowTo = -1;
if (wrapIdx !== -1) {
const firstSize = wrapIdx;
const secondSize = dayNums.length - wrapIdx;
if (secondSize >= firstSize) {
primaryFrom = wrapIdx;
primaryTo = dayNums.length;
overflowFrom = 0;
overflowTo = wrapIdx;
} else {
primaryFrom = 0;
primaryTo = wrapIdx;
overflowFrom = wrapIdx;
overflowTo = dayNums.length;
}
}
const prevMonth = ((monthInfo.month - 1) + 12) % 12;
const prevYear = monthInfo.month === 0 ? monthInfo.year - 1 : monthInfo.year;
const result: (string | null)[] = [];
for (let i = 0; i < dayNums.length; i++) {
const d = dayNums[i];
if (d === null) {
result.push(null);
continue;
}
const inOverflow =
overflowFrom !== -1 && i >= overflowFrom && i < overflowTo;
const month = inOverflow ? prevMonth : monthInfo.month;
const year = inOverflow ? prevYear : monthInfo.year;
const iso = new Date(Date.UTC(year, month, d));
result.push(Number.isNaN(iso.getTime()) ? null : iso.toISOString().slice(0, 10));
}
return result;
}
function validateDayNames(
dayNameRow: ReadonlyArray<unknown>,
columnDates: (string | null)[],
): number[] {
const mismatches: number[] = [];
const expected = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for (let i = 0; i < columnDates.length; i++) {
const date = columnDates[i];
const dayName = dayNameRow[i];
if (!date || typeof dayName !== "string") continue;
const want = expected[new Date(date).getUTCDay()];
if (dayName.trim() !== want) mismatches.push(i);
}
return mismatches;
}
function toNumber(input: unknown): number | null {
if (input === null || input === undefined) return null;
if (typeof input === "number") return Number.isFinite(input) ? input : null;
if (typeof input === "string") {
const trimmed = input.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
return null;
}