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:
parent
f5c063765d
commit
2ace4365e6
5 changed files with 754 additions and 90 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
138
src/lib/timesheet-casual-parse.test.ts
Normal file
138
src/lib/timesheet-casual-parse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
282
src/lib/timesheet-casual-parse.ts
Normal file
282
src/lib/timesheet-casual-parse.ts
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue