feat(timesheets): enhance import functionality with site linking and employee creation

- Updated the timesheet import route to support optional metadata fields for site linking and auto-creation of missing employees.
- Enhanced parsing to detect multiple file formats, including Hills Casual Matrix and long-format CSV/XLSX.
- Modified response structure to include detected format and created employees in the JSON response.
- Improved UI components to allow project selection and options for creating new workers during import.
- Refactored import logic to ensure a seamless user experience when handling timesheet uploads.
This commit is contained in:
DEls 2026-08-26 12:44:02 +02:00
parent f5c063765d
commit 2ace4365e6
5 changed files with 754 additions and 90 deletions

View file

@ -2,39 +2,67 @@ import { NextResponse } from "next/server";
import Papa from "papaparse";
import * as XLSX from "xlsx";
import { pbAdmin } from "@/lib/pb";
import { parseHillsTimesheet, type ParsedTimesheetRow } from "@/lib/timesheet-grid-parse";
import {
parseHillsTimesheet,
type ParsedTimesheetRow,
} from "@/lib/timesheet-grid-parse";
import {
looksLikeCasualMatrix,
parseCasualTimesheet,
} from "@/lib/timesheet-casual-parse";
/**
* POST /api/timesheets/import
*
* Accepts multipart/form-data with a single `file` field. Two formats:
* Accepts multipart/form-data with a single `file` field, plus optional
* metadata fields:
*
* 1. Long-format CSV/XLSX/XLS (one row per employee per day):
* employee_no - matches EMPLOYEES.employee_no
* date - yyyy-mm-dd
* hours_worked - number
* siteId - when set, any missing employees get linked
* to this site on auto-create
* createMissingEmployees - "true"/"false". When true, employee_nos
* that don't exist yet are created as casual
* workers tied to the supplied siteId
*
* 2. Hills-style wide-format XLSX/XLS (one row per employee, daily
* hours across columns). Parsed by `parseHillsTimesheet` and
* converted to the same long-format rows before insertion.
* Three formats are auto-detected from file contents (extension is just
* a hint):
*
* 1. Hills Casual Matrix (XLS/XLSX): rows show "Matrix View Report" —
* parser emits one long-format row per (employee, day). Hours use
* HH:MM, "Absent" cells are skipped, names with leading commas are
* cleaned.
*
* 2. Permanent wide format (XLSX): the existing `parseHillsTimesheet`
* parser — one row per employee, daily hours across columns.
*
* 3. Long-format CSV/XLSX: header row with employee_no, date,
* hours_worked.
*
* The route:
* 1. Detects the format from the file extension.
* 2. Parses to a uniform array of { employeeNo, date, hoursWorked }.
* 3. Creates one DATA_IMPORT_BATCHES record (batch_type=timesheets)
* and attaches the raw file for the audit trail.
* 4. Resolves each row's employee_no to the EMPLOYEES.id.
* 5. Inserts a TIMESHEETS row per valid input, linked to the batch.
* 1. Detects the format and parses to a uniform array of
* { employeeNo, date, hoursWorked }.
* 2. Optionally bulk-creates any missing employees (tied to siteId).
* 3. Creates one DATA_IMPORT_BATCHES record and attaches the raw
* file for the audit trail.
* 4. Inserts a TIMESHEETS row per valid input, linked to the batch.
*
* Returns JSON: { batchId, totalRows, imported, skipped, errors[] }.
* Returns JSON: { batchId, totalRows, imported, skipped, errors[],
* detectedFormat, createdEmployees[] }.
*/
interface LongFormatRow {
employeeNo: string;
name?: string;
date: string;
hoursWorked: number;
}
interface ParseResult {
rows: LongFormatRow[];
/** e.g. "casual-matrix", "permanent-wide", "long-format-csv" */
detectedFormat: string;
warnings: string[];
}
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file");
@ -42,12 +70,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
let longFormatRows: LongFormatRow[];
let parseWarnings: string[] = [];
const siteId = String(form.get("siteId") ?? "").trim() || null;
const createMissingEmployees =
String(form.get("createMissingEmployees") ?? "").toLowerCase() === "true";
let parsed: ParseResult;
try {
const result = await parseFile(file);
longFormatRows = result.rows;
parseWarnings = result.warnings;
parsed = await parseFile(file);
} catch (e) {
return NextResponse.json(
{
@ -58,6 +87,26 @@ export async function POST(request: Request) {
);
}
if (parsed.rows.length === 0) {
return NextResponse.json(
{
error: `Detected format: ${parsed.detectedFormat}, but no timesheet rows could be extracted.`,
},
{ status: 400 },
);
}
// Optionally auto-create missing employees BEFORE inserting timesheets.
// We need them to exist so the timesheets have an employee.id to link to.
const createdEmployees: { employeeNo: string; name: string }[] = [];
if (createMissingEmployees && siteId) {
const created = await bulkCreateMissingEmployees(
parsed.rows,
siteId,
);
createdEmployees.push(...created);
}
const pb = await pbAdmin();
// Create the batch with the original file attached. Timesheets stay
@ -73,23 +122,17 @@ export async function POST(request: Request) {
attempts: 0,
});
// employee_no → id lookup
const allEmployees = await pb.collection("employees").getFullList({
fields: "id,employee_no",
});
const empByNo = new Map<string, string>();
for (const e of allEmployees) {
if (e.employee_no) empByNo.set(e.employee_no, e.id);
}
// employee_no → id lookup, rebuilt after any auto-create.
const empByNo = await loadEmployeeLookup();
let imported = 0;
const errors: { row: number; reason: string }[] = parseWarnings.map((w) => ({
const errors: { row: number; reason: string }[] = parsed.warnings.map((w) => ({
row: 0,
reason: w,
}));
for (let i = 0; i < longFormatRows.length; i++) {
const row = longFormatRows[i];
for (let i = 0; i < parsed.rows.length; i++) {
const row = parsed.rows[i];
const employeeId = empByNo.get(row.employeeNo);
if (!employeeId) {
errors.push({
@ -117,52 +160,67 @@ export async function POST(request: Request) {
return NextResponse.json({
batchId: batch.id,
totalRows: longFormatRows.length,
totalRows: parsed.rows.length,
imported,
skipped: errors.length,
errors,
detectedFormat: parsed.detectedFormat,
createdEmployees,
});
}
async function parseFile(file: File): Promise<{
rows: LongFormatRow[];
warnings: string[];
}> {
/* ------------------------------------------------------------------ */
/* Parsing */
/* ------------------------------------------------------------------ */
async function parseFile(file: File): Promise<ParseResult> {
const name = file.name.toLowerCase();
if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
if (name.endsWith(".xlsx") || name.endsWith(".xls") || name.endsWith(".xlt")) {
return parseSpreadsheet(file);
}
// CSV (or anything else) - parse with Papa
return parseCsv(file);
}
async function parseSpreadsheet(file: File): Promise<{
rows: LongFormatRow[];
warnings: string[];
}> {
async function parseSpreadsheet(file: File): Promise<ParseResult> {
const buffer = Buffer.from(await file.arrayBuffer());
const wb = XLSX.read(buffer, { type: "buffer" });
if (wb.SheetNames.length === 0) {
throw new Error("Workbook has no sheets");
}
// Try every sheet - the first one that looks like a Hills wide-format
// sheet wins. If none do, fall back to long-format CSV-style parsing
// of the first sheet.
// Walk every sheet; first one we recognise as a known format wins.
for (const sheetName of wb.SheetNames) {
const sh = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(sh, { header: 1, defval: null });
const result = parseHillsTimesheet(rows as unknown[][], sheetName);
if (result.employeeRowsImported > 0) {
const rows = XLSX.utils.sheet_to_json(sh, { header: 1, defval: null }) as unknown[][];
if (looksLikeCasualMatrix(rows)) {
const result = parseCasualTimesheet(rows, sheetName);
if (result.rows.length > 0) {
return {
rows: result.rows.map((r) => ({
employeeNo: r.employeeNo,
name: r.name,
date: r.date,
hoursWorked: r.hoursWorked,
})),
detectedFormat: "casual-matrix",
warnings: result.errors,
};
}
}
const wide = parseHillsTimesheet(rows, sheetName);
if (wide.employeeRowsImported > 0) {
return {
rows: result.rows.map(toLongFormat),
warnings: result.errors,
rows: wide.rows.map(toLongFormat),
detectedFormat: "permanent-wide",
warnings: wide.errors,
};
}
}
// No Hills-shaped sheet found - fall back to the first sheet as
// long-format (employee_no/date/hours_worked header row).
// Neither wide parser matched — fall back to long-format header parsing
// of the first sheet.
const sh = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sh, {
defval: null,
@ -177,16 +235,13 @@ async function parseSpreadsheet(file: File): Promise<{
}
if (out.length === 0) {
throw new Error(
`No Hills wide-format sheet found and no long-format rows in the workbook`,
"No casual-matrix, permanent-wide, or long-format rows found in the workbook",
);
}
return { rows: out, warnings: [] };
return { rows: out, detectedFormat: "long-format-xlsx", warnings: [] };
}
async function parseCsv(file: File): Promise<{
rows: LongFormatRow[];
warnings: string[];
}> {
async function parseCsv(file: File): Promise<ParseResult> {
const text = await file.text();
const parsed = Papa.parse<Record<string, string>>(text, {
header: true,
@ -195,7 +250,10 @@ async function parseCsv(file: File): Promise<{
});
if (parsed.errors.length > 0 && parsed.data.length === 0) {
throw new Error(
`CSV could not be parsed: ${parsed.errors.slice(0, 3).map((e) => e.message).join("; ")}`,
`CSV could not be parsed: ${parsed.errors
.slice(0, 3)
.map((e) => e.message)
.join("; ")}`,
);
}
const out: LongFormatRow[] = [];
@ -206,7 +264,7 @@ async function parseCsv(file: File): Promise<{
if (!empNo || !date || !Number.isFinite(hours)) continue;
out.push({ employeeNo: empNo.toUpperCase(), date, hoursWorked: hours });
}
return { rows: out, warnings: [] };
return { rows: out, detectedFormat: "long-format-csv", warnings: [] };
}
function toLongFormat(r: ParsedTimesheetRow): LongFormatRow {
@ -216,3 +274,82 @@ function toLongFormat(r: ParsedTimesheetRow): LongFormatRow {
hoursWorked: r.hoursWorked,
};
}
/* ------------------------------------------------------------------ */
/* Auto-create missing employees */
/* ------------------------------------------------------------------ */
async function loadEmployeeLookup(): Promise<Map<string, string>> {
const pb = await pbAdmin();
const all = await pb.collection("employees").getFullList({
fields: "id,employee_no",
});
const m = new Map<string, string>();
for (const e of all) {
if (e.employee_no) m.set(e.employee_no.toUpperCase(), e.id);
}
return m;
}
/**
* Bulk-create any employee_nos from the parsed rows that don't yet
* exist in PocketBase. Each new worker is a casual tied to the supplied
* site, with sane defaults for fields we can't infer from the sheet.
*
* Returns the list of workers actually created (employee_no + name).
*/
async function bulkCreateMissingEmployees(
rows: LongFormatRow[],
siteId: string,
): Promise<{ employeeNo: string; name: string }[]> {
const pb = await pbAdmin();
const existing = await loadEmployeeLookup();
const seen = new Set<string>();
const toCreate = new Map<string, string>(); // employeeNo → name
for (const row of rows) {
const key = row.employeeNo.toUpperCase();
if (seen.has(key)) continue;
seen.add(key);
if (existing.has(key)) continue;
if (toCreate.has(key)) continue;
toCreate.set(key, row.name ?? key);
}
if (toCreate.size === 0) return [];
// Look up the site so we can stamp project_code on the new workers.
let site: { project_code?: string } | null = null;
try {
site = await pb.collection("sites").getOne(siteId);
} catch {
// site was deleted between dialog open and submit — skip project_code
}
const created: { employeeNo: string; name: string }[] = [];
for (const [employeeNo, name] of toCreate) {
try {
await pb.collection("employees").create({
employee_no: employeeNo,
name,
worker_type: "casual",
site: siteId,
project_code: site?.project_code ?? "",
rate_per_hour: 0,
hours_per_day_standard: 9,
position: "",
});
created.push({ employeeNo, name });
} catch (err) {
// Swallow per-worker errors; they'll surface as "Unknown
// employee_no" on the timesheet insert and be counted under
// skipped. We don't fail the whole import.
console.warn(
`Failed to create employee ${employeeNo}:`,
err instanceof Error ? err.message : err,
);
}
}
return created;
}

View file

@ -99,9 +99,10 @@ function sumHoursInWindow(
export default async function EmployeesPage() {
const pb = await pbAdmin();
const [employees, timesheets] = await Promise.all([
const [employees, timesheets, sites] = await Promise.all([
pb.collection("employees").getFullList({ sort: "name", expand: "site" }),
pb.collection("timesheets").getFullList(),
pb.collection("sites").getFullList({ sort: "name" }),
]);
const hoursByEmployee = new Map<string, number>();
@ -165,7 +166,15 @@ export default async function EmployeesPage() {
<Topbar
title="Workforce"
subtitle="Every worker, every project — backed by PocketBase."
right={<ImportTimesheetsButton />}
right={
<ImportTimesheetsButton
sites={sites.map((s) => ({
id: s.id,
name: s.name,
project_code: s.project_code,
}))}
/>
}
/>
<EmployeesView
rows={rows}

View file

@ -4,6 +4,8 @@ import * as React from "react";
import { format } from "date-fns";
import { FileSpreadsheet, Loader2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
@ -12,8 +14,19 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Phase = "idle" | "uploading" | "done" | "error";
export interface ImportSiteOption {
id: string;
name: string;
project_code: string;
}
interface ImportResult {
batchId: string;
@ -21,16 +34,20 @@ interface ImportResult {
imported: number;
skipped: number;
errors: { row: number; reason: string }[];
detectedFormat?: string;
createdEmployees?: { employeeNo: string; name: string }[];
}
const SAMPLE_HEADERS = ["employee_no", "date", "hours_worked"];
type Phase = "idle" | "uploading" | "done" | "error";
export function ImportTimesheetsButton() {
export function ImportTimesheetsButton({ sites }: { sites: ImportSiteOption[] }) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [phase, setPhase] = React.useState<Phase>("idle");
const [result, setResult] = React.useState<ImportResult | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [siteId, setSiteId] = React.useState<string>("");
const [createMissingEmployees, setCreateMissingEmployees] = React.useState(true);
const inputRef = React.useRef<HTMLInputElement>(null);
const reset = React.useCallback(() => {
@ -43,17 +60,22 @@ export function ImportTimesheetsButton() {
const close = () => {
setOpen(false);
// Defer until the dialog animates out
setTimeout(reset, 200);
};
const upload = async () => {
if (!file) return;
if (!siteId) {
setErrorMsg("Pick a project so we know which site these workers belong to");
return;
}
setPhase("uploading");
setErrorMsg(null);
try {
const form = new FormData();
form.append("file", file);
form.append("siteId", siteId);
form.append("createMissingEmployees", createMissingEmployees ? "true" : "false");
const res = await fetch("/api/timesheets/import", {
method: "POST",
body: form,
@ -81,6 +103,21 @@ export function ImportTimesheetsButton() {
setErrorMsg(null);
};
const formatLabel = (fmt?: string) => {
switch (fmt) {
case "casual-matrix":
return "Hills casual matrix (auto-detected)";
case "permanent-wide":
return "Permanent wide format (auto-detected)";
case "long-format-csv":
return "Long-format CSV";
case "long-format-xlsx":
return "Long-format XLSX";
default:
return fmt ?? "Unknown";
}
};
return (
<>
<Button onClick={() => setOpen(true)} className="gap-2">
@ -97,31 +134,74 @@ export function ImportTimesheetsButton() {
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Import weekly timesheets</DialogTitle>
<DialogTitle>Import timesheets</DialogTitle>
<DialogDescription>
Upload a CSV — each row creates one timesheet entry linked to
the source file for audit.
Pick the project these hours were worked on. Format is detected
automatically (Hills casual matrix, permanent wide, or
long-format CSV/XLSX).
</DialogDescription>
</DialogHeader>
{phase === "done" && result ? (
<ResultSummary result={result} onClose={close} />
<ResultSummary
result={result}
formatLabel={formatLabel(result.detectedFormat)}
onClose={close}
/>
) : (
<div className="space-y-4">
{/* Expected format */}
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Expected columns (header row required)
{/* Project selector */}
<div className="space-y-1">
<Label
htmlFor="import-site"
className="text-xs font-medium text-muted-foreground"
>
Project these hours belong to
</Label>
<Select value={siteId} onValueChange={setSiteId}>
<SelectTrigger id="import-site" className="w-full">
<SelectValue placeholder="Choose a project…" />
</SelectTrigger>
<SelectContent>
{sites.length === 0 ? (
<SelectItem value="__none" disabled>
No projects yet — add one under Sites first
</SelectItem>
) : (
sites.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.project_code})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
The hours are linked to this site so workers show up under
the right project.
</p>
<div className="flex flex-wrap gap-1.5">
{SAMPLE_HEADERS.map((h) => (
<Badge key={h} variant="outline" className="font-mono text-xs">
{h}
</Badge>
))}
</div>
</div>
{/* Auto-create checkbox */}
<label className="flex items-start gap-2 rounded-md border border-border bg-muted/20 p-3 text-sm">
<input
type="checkbox"
checked={createMissingEmployees}
onChange={(e) => setCreateMissingEmployees(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-input text-[#F2B165] focus:ring-[#F2B165]"
/>
<span>
<span className="font-medium">
Create new workers for unmatched employee numbers
</span>
<span className="block text-xs text-muted-foreground">
Casual workers from this upload get added automatically and
linked to the selected project. Untick to skip unknown
employees instead.
</span>
</span>
</label>
{/* File picker */}
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-muted/20 px-4 py-8 text-center transition-colors hover:border-[#F2B165]/60 hover:bg-[#F2B165]/5">
<input
@ -155,7 +235,7 @@ export function ImportTimesheetsButton() {
<Upload className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium">Click to choose an Excel or CSV file</p>
<p className="text-xs text-muted-foreground">
Weekly timesheet export from your payroll system
Detects Hills casual matrix, permanent wide, or long-format CSV
</p>
</>
)}
@ -173,7 +253,7 @@ export function ImportTimesheetsButton() {
</Button>
<Button
onClick={upload}
disabled={!file || phase === "uploading"}
disabled={!file || !siteId || phase === "uploading"}
className="gap-2"
>
{phase === "uploading" ? (
@ -199,20 +279,26 @@ export function ImportTimesheetsButton() {
function ResultSummary({
result,
formatLabel,
onClose,
}: {
result: ImportResult;
formatLabel: string;
onClose: () => void;
}) {
const created = result.createdEmployees ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Detected:</span>
<Badge variant="outline" className="border-[#F2B165]/40 bg-[#F2B165]/10 text-[#F2B165]">
{formatLabel}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3">
<SummaryStat label="Total rows" value={result.totalRows} />
<SummaryStat
label="Imported"
value={result.imported}
tone="success"
/>
<SummaryStat label="Imported" value={result.imported} tone="success" />
<SummaryStat
label="Skipped"
value={result.skipped}
@ -220,9 +306,21 @@ function ResultSummary({
/>
</div>
{created.length > 0 && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Created {created.length} new casual worker
{created.length === 1 ? "" : "s"} and linked them to the project.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Set their hourly rate on the Workforce page once payroll confirms it.
</p>
</div>
)}
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
Batch <span className="font-mono text-foreground">{result.batchId}</span>{" "}
created and the CSV is attached for the audit trail.
created and the original file is attached for the audit trail.
</div>
{result.errors.length > 0 && (

View file

@ -0,0 +1,138 @@
import { describe, it, expect } from "vitest";
import XLSX from "xlsx";
import * as path from "node:path";
import {
looksLikeCasualMatrix,
parseCasualTimesheet,
parseHoursCell,
} from "./timesheet-casual-parse";
const SAMPLE_PATH = path.resolve(
process.cwd(),
"RAW_DOCS/The Hills Casual MatrixTReport August 2026 (1).xls",
);
function loadFirstSheet() {
const wb = XLSX.readFile(SAMPLE_PATH);
const sh = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sh, { header: 1, defval: null }) as unknown[][];
return { rows, sheetName: wb.SheetNames[0] };
}
describe("parseHoursCell", () => {
it("parses HH:MM as decimal hours", () => {
expect(parseHoursCell("09:00")).toBe(9);
expect(parseHoursCell("07:30")).toBe(7.5);
expect(parseHoursCell("07:15")).toBe(7.25);
expect(parseHoursCell("0:00")).toBe(0);
});
it("handles absent / off markers", () => {
expect(parseHoursCell("Absent")).toBe(0);
expect(parseHoursCell("absent")).toBe(0);
expect(parseHoursCell("Off")).toBe(0);
expect(parseHoursCell("-")).toBe(0);
});
it("handles comma decimals and plain numbers", () => {
expect(parseHoursCell("7,5")).toBe(7.5);
expect(parseHoursCell("8.5")).toBe(8.5);
expect(parseHoursCell("0")).toBe(0);
});
it("rejects garbage", () => {
expect(parseHoursCell("")).toBeNull();
expect(parseHoursCell(null)).toBeNull();
expect(parseHoursCell(undefined)).toBeNull();
expect(parseHoursCell("nope")).toBeNull();
expect(parseHoursCell("25:99")).toBeNull();
});
});
describe("looksLikeCasualMatrix", () => {
it("detects the real Hills casual file", () => {
const { rows } = loadFirstSheet();
expect(looksLikeCasualMatrix(rows)).toBe(true);
});
it("rejects a permanent-format-style row", () => {
const rows: unknown[][] = [
[null, "August 2026"],
[null, null, null, 21, 22, 23],
["RESISTANCE MUGUTI", null, null, "MCW058", 9, 9, 9],
];
expect(looksLikeCasualMatrix(rows)).toBe(false);
});
});
describe("parseCasualTimesheet (real Hills casual file)", () => {
it("parses the real file", () => {
const { rows, sheetName } = loadFirstSheet();
const result = parseCasualTimesheet(rows, sheetName);
expect(result.errors).toEqual([]);
expect(result.sheetName).toBe("Sheet");
expect(result.periodStart).toBe("2026-07-21");
expect(result.periodEnd).toBe("2026-08-20");
expect(result.employeeRowsParsed).toBeGreaterThan(20);
// Every row should have a valid SUB employee_no, date in range, hours > 0
for (const r of result.rows) {
expect(r.employeeNo).toMatch(/^S(U)?B\d+$/i);
expect(r.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(r.date >= result.periodStart!).toBe(true);
expect(r.date <= result.periodEnd!).toBe(true);
expect(r.hoursWorked).toBeGreaterThan(0);
expect(r.hoursWorked).toBeLessThanOrEqual(24);
}
});
it("strips the leading comma from names like ', CHARLESleballo'", () => {
const { rows, sheetName } = loadFirstSheet();
const result = parseCasualTimesheet(rows, sheetName);
const names = new Set(result.employees.map((e) => e.name));
expect(names.has("CHARLESleballo")).toBe(true);
expect([...names].some((n) => n.startsWith(","))).toBe(false);
});
it("emits zero day-rows for an employee that's all Absent", () => {
// Hand-rolled mini-sheet: one row, every cell is Absent.
const rows: unknown[][] = [
["Matrix View Report"],
[],
["Week 30", null, null, null, null, null, "Week 30 Total"],
[
null,
null,
null,
null,
"21 Jul 2026",
"22 Jul 2026",
null,
"23 Jul 2026",
"24 Jul 2026",
"25 Jul 2026",
],
["EmployeeName", null, null, "EmployeeNo", null, null, null, null, null],
["TEST, USER", null, null, "SUB9999", "Absent", "Absent", "Absent", "Absent", "Absent"],
];
const result = parseCasualTimesheet(rows, "Sheet");
expect(result.employeeRowsParsed).toBe(0);
expect(result.rows.length).toBe(0);
// The employee should still be in the employees list (their name is
// known even if they had no hours).
expect(result.employees.find((e) => e.employeeNo === "SUB9999")?.name).toBe(
"TEST, USER",
);
});
it("rejects an unparseable date row gracefully", () => {
const rows: unknown[][] = [
["Matrix View Report"],
["NOT A DATE", null, null, null, null, null, null, null, null, null],
["TEST, USER", null, null, "SUB0001", "08:00"],
];
const result = parseCasualTimesheet(rows, "Sheet");
expect(result.errors.some((e) => e.toLowerCase().includes("date row"))).toBe(
true,
);
expect(result.rows.length).toBe(0);
});
});

View file

@ -0,0 +1,282 @@
/**
* Parser for the "Hills Casual Matrix" / "Matrix View Report" XLS
* format used by site foremen for casual workers.
*
* Layout (row indices 0-based):
*
* R0 : empty
* R1 : "Matrix View Report" in col 1 ← fingerprint for casual
* R2 : empty
* R3 : week labels ("Week 30", "Week 30 Total", "Week 31", ...)
* R4 : date strings ("21 Jul 2026 Tue") with blank cols where no day
* R5 : ["EmployeeName", ..., "EmployeeNo", ...]
* R6+ : ["<name>", null, null, "<empNo>", "<HH:MM>", ..., "Absent", ...]
* ... : more employees
* Rn-2 : "Grand Total" row (skip)
*
* Quirks handled here:
* - Day cells use HH:MM ("09:00", "07:30"). "Absent" → 0 hours.
* - Some totals use comma decimal ("179,45") — we parse as 179.45 hrs.
* - Names sometimes start with a stray comma (", CHARLESleballo") → strip.
* - Week-total / Grand-total columns have a value in the data row but
* no date in the date row — we identify them by that and skip them.
*
* Output: same long-format rows as parseHillsTimesheet:
* { employeeNo, name, date (YYYY-MM-DD), hoursWorked }
*/
export interface CasualTimesheetRow {
employeeNo: string;
name: string;
/** YYYY-MM-DD */
date: string;
hoursWorked: number;
}
export interface CasualTimesheet {
siteName: string | null;
/** Earliest day-column date across the sheet (YYYY-MM-DD). */
periodStart: string | null;
/** Latest day-column date (YYYY-MM-DD). */
periodEnd: string | null;
/** Sheet name as it appeared in the workbook. */
sheetName: string;
rows: CasualTimesheetRow[];
/** Per-employee summary: employeeNo → name. */
employees: { employeeNo: string; name: string }[];
/** Soft errors (e.g. unparseable hour cell). */
errors: string[];
employeeRowsParsed: number;
totalDayCells: number;
}
/**
* Detect whether a sheet looks like a casual-matrix file.
* Cheap check that doesn't depend on the heavy parser.
*/
export function looksLikeCasualMatrix(rows: ReadonlyArray<ReadonlyArray<unknown>>): boolean {
// First ~6 rows should contain "Matrix View Report" somewhere
for (let i = 0; i < Math.min(rows.length, 6); i++) {
const r = rows[i] || [];
for (let j = 0; j < r.length; j++) {
const cell = String(r[j] ?? "").trim();
if (cell === "Matrix View Report") return true;
}
}
return false;
}
/**
* Parse a Hills-Casual-Matrix sheet. The caller is expected to have
* verified the format with `looksLikeCasualMatrix` first; we still
* attempt a fallback so a malformed file produces a clear error.
*/
export function parseCasualTimesheet(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
sheetName: string,
): CasualTimesheet {
const errors: string[] = [];
const result: CasualTimesheetRow[] = [];
const employeesMap = new Map<string, string>();
// Locate the date row (the row that contains "21 Jul 2026"-style strings
// OR plain date-like strings). Also locate the data rows by finding the
// first row whose col 0 looks like a name.
const dateRowIdx = findDateRow(rows);
if (dateRowIdx === -1) {
errors.push("Could not find the date row in the casual matrix sheet");
return emptyResult(sheetName, errors);
}
const dateRow = rows[dateRowIdx];
// Find the first employee data row (after the date row).
const firstDataRowIdx = findFirstEmployeeRow(rows, dateRowIdx);
if (firstDataRowIdx === -1) {
errors.push("Could not find any employee rows below the date row");
return emptyResult(sheetName, errors);
}
// Build column → date map. Only columns that have a parseable date in
// the date row are real day columns; everything else (week totals,
// spacers, grand total) is skipped.
const columnDates = buildColumnDates(dateRow);
const validDates = columnDates.filter((d): d is string => d !== null);
const periodStart = validDates[0] ?? null;
const periodEnd = validDates[validDates.length - 1] ?? null;
// Walk every employee row and emit one CasualTimesheetRow per
// non-empty hour cell that maps to a day column.
let totalDayCells = 0;
let employeeRowsParsed = 0;
for (let r = firstDataRowIdx; r < rows.length; r++) {
const row = rows[r] || [];
const name = cleanName(row[0]);
const employeeNo = String(row[3] ?? "").trim().toUpperCase();
// Stop at the Grand Total row.
if (name === "GRAND TOTAL" || employeeNo === "") {
continue;
}
if (!name) continue;
employeesMap.set(employeeNo, name);
let employeeRowHadHours = false;
for (let col = 0; col < row.length; col++) {
const date = columnDates[col];
if (!date) continue;
const hours = parseHoursCell(row[col]);
if (hours === null || hours <= 0) continue;
result.push({ employeeNo, name, date, hoursWorked: hours });
totalDayCells++;
employeeRowHadHours = true;
}
if (employeeRowHadHours) employeeRowsParsed++;
}
return {
siteName: null,
periodStart,
periodEnd,
sheetName,
rows: result,
employees: Array.from(employeesMap.entries()).map(([employeeNo, name]) => ({
employeeNo,
name,
})),
errors,
employeeRowsParsed,
totalDayCells,
};
}
function emptyResult(sheetName: string, errors: string[]): CasualTimesheet {
return {
siteName: null,
periodStart: null,
periodEnd: null,
sheetName,
rows: [],
employees: [],
errors,
employeeRowsParsed: 0,
totalDayCells: 0,
};
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function findDateRow(rows: ReadonlyArray<ReadonlyArray<unknown>>): number {
// Walk the top of the sheet looking for a row with several "21 Jul 2026"-style
// cells. We accept rows within the first 12 (header area).
for (let i = 0; i < Math.min(rows.length, 12); i++) {
const r = rows[i] || [];
let dateHits = 0;
for (let j = 0; j < r.length; j++) {
if (parseDateLoose(r[j]) !== null) dateHits++;
}
if (dateHits >= 5) return i;
}
return -1;
}
function findFirstEmployeeRow(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
afterIdx: number,
): number {
for (let i = afterIdx + 1; i < rows.length; i++) {
const r = rows[i] || [];
const name = String(r[0] ?? "").trim();
const empNo = String(r[3] ?? "").trim();
// A real employee row has a non-empty name AND a non-empty employee_no
// that looks like our ID format (SUB..., MCW..., or similar).
if (name && empNo && /^[A-Z]{2,5}\d{2,}/i.test(empNo)) {
return i;
}
}
return -1;
}
function buildColumnDates(dateRow: ReadonlyArray<unknown>): (string | null)[] {
const out: (string | null)[] = [];
for (let i = 0; i < dateRow.length; i++) {
out.push(parseDateLoose(dateRow[i]));
}
return out;
}
/**
* Parse "21 Jul 2026", "21 Jul", "21 Jul 2026 Tue", etc. into YYYY-MM-DD.
* Returns null if the cell isn't a date.
*/
function parseDateLoose(cell: unknown): string | null {
if (typeof cell !== "string") return null;
const s = cell.trim();
if (!s) return null;
// Match "<day> <Mon> [YYYY] [Dow]" — YYYY and day-of-week are optional.
const m = /^(\d{1,2})\s+([A-Za-z]{3,9})(?:\s+(\d{4}))?(?:\s+[A-Za-z]{3,})?$/.exec(s);
if (!m) return null;
const day = parseInt(m[1], 10);
const monthName = m[2].toLowerCase().slice(0, 3);
const year = m[3] ? parseInt(m[3], 10) : new Date().getUTCFullYear();
const monthIdx = MONTHS[monthName];
if (monthIdx === undefined) return null;
const d = new Date(Date.UTC(year, monthIdx, day));
if (
Number.isNaN(d.getTime()) ||
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== monthIdx ||
d.getUTCDate() !== day
) {
return null;
}
return d.toISOString().slice(0, 10);
}
const MONTHS: Record<string, number> = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
};
/**
* Parse a cell that holds hours, like "09:00", "07:30", "Absent",
* or a plain decimal "8.5". Returns null for blank/unparseable cells.
*/
export function parseHoursCell(cell: unknown): number | null {
if (cell === null || cell === undefined) return null;
const raw = String(cell).trim();
if (!raw) return null;
const lower = raw.toLowerCase();
if (lower === "absent" || lower === "off" || lower === "-") return 0;
// HH:MM or H:MM
const colon = /^(\d{1,2}):(\d{2})$/.exec(raw);
if (colon) {
const h = parseInt(colon[1], 10);
const m = parseInt(colon[2], 10);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (m >= 60) return null;
return h + m / 60;
}
// Decimal hours with optional comma decimal ("7,5" → 7.5, "8.5" → 8.5)
const normalized = raw.replace(",", ".");
const n = Number(normalized);
if (Number.isFinite(n) && n >= 0 && n <= 24) return n;
return null;
}
/**
* Strip the leading comma + whitespace that some rows have. Also
* trims and collapses internal whitespace.
*/
function cleanName(cell: unknown): string {
if (typeof cell !== "string") return "";
return cell.trim().replace(/^,+\s*/, "").replace(/\s+/g, " ").trim();
}