- 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.
328 lines
10 KiB
TypeScript
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;
|
|
}
|