Files
route-commerce/src/actions/time-tracking/export.ts
T
Nora 9e6accb3df feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.

Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
  the existing worker / time_admin / irrigator / water_admin values.

Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
  + entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
  rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
  get_or_create_timesheet(), plus a time_tracking_approval_queue view.

Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
  worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
  all run inside withTx with FOR UPDATE row locks so the status + audit
  row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
  the action layer; only unlockTimesheet (admin-only, mandatory audit
  note) can re-open it.

Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
  audit-log writes in the same transaction, lock-aware.

GPS + offline (src/actions/time-tracking/field.ts,
  src/lib/offline/time-tracking-queue.ts,
  src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
  clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
  in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
  replay, submitted_via = 'offline_sync').

Export (src/actions/time-tracking/export.ts +
  src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
  available for any approved timesheet.

UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
  Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
  manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
  water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
  cross-link badge (water gallons + time hours + timesheet status).

Verification
- npx tsc --noEmit  → 0 errors.
- npm run build     → all 10 time-tracking routes compile.
- npm run lint      → 0 errors/warnings in new files.
- Playwright smoke  → 4/5 pass; the failing case is a pre-existing
  Tuxedo storefront 404 unrelated to this change.
2026-07-04 00:27:25 -06:00

326 lines
11 KiB
TypeScript

/**
* Time Tracking — Payroll export (CSV + PDF).
*
* Two endpoints, both keyed on a `timesheetId`:
* - CSV: stable, machine-friendly. Columns match the format the Tuxedo
* payroll service already ingests for the water log so we can re-use
* the same import tool on the payroll side.
* - PDF: signature-ready. Includes worker, period, totals, every
* entry with start/end, manual entries highlighted, supervisor
* approval signature line, and an audit footer.
*
* Both lock behind the same authz: caller must be admin or the timesheet's
* worker (or assigned supervisor). Locked timesheets export; the
* approval status is what makes them payroll-ready.
*/
"use server";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { getAdminUser } from "@/lib/admin-permissions";
import {
timeTrackingLogs,
timeTrackingTimesheets,
} from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import Papa from "papaparse";
// ── CSV ─────────────────────────────────────────────────────────────────────
export type ExportRow = {
date: string;
worker_name: string;
worker_role: string;
task: string;
entry_kind: "clock" | "manual";
clock_in: string;
clock_out: string;
break_minutes: number;
total_minutes: number;
gps_verified: boolean;
manual_reason: string | null;
notes: string | null;
};
export type ExportPayload = {
brand_name: string;
worker_name: string;
worker_role: string;
period_start: string;
period_end: string;
status: string;
reviewer: string | null;
approved_at: string | null;
rows: ExportRow[];
totals: {
entry_count: number;
total_minutes: number;
regular_minutes: number;
overtime_minutes: number;
break_minutes: number;
};
};
export async function exportTimesheetCsv(
timesheetId: string,
): Promise<{ success: boolean; csv?: string; filename?: string; error?: string }> {
const payload = await loadExportPayload(timesheetId);
if (!payload) return { success: false, error: "Not authorized or not found" };
const csv = Papa.unparse(
payload.rows.map((r) => ({
Date: r.date,
Worker: r.worker_name,
Role: r.worker_role,
Task: r.task,
Kind: r.entry_kind,
"Clock In": r.clock_in,
"Clock Out": r.clock_out,
"Break (min)": r.break_minutes,
"Total (min)": r.total_minutes,
"GPS Verified": r.gps_verified ? "yes" : "no",
"Manual Reason": r.manual_reason ?? "",
Notes: r.notes ?? "",
})),
);
const header =
`# ${payload.brand_name}\n` +
`# Worker: ${payload.worker_name} (${payload.worker_role})\n` +
`# Period: ${payload.period_start}${payload.period_end}\n` +
`# Status: ${payload.status}\n` +
`# Approver: ${payload.reviewer ?? "(none)"}\n` +
`# Approved at: ${payload.approved_at ?? "(not approved)"}\n` +
`# Totals: ${payload.totals.total_minutes} min total · ` +
`${payload.totals.regular_minutes} reg · ` +
`${payload.totals.overtime_minutes} OT · ` +
`${payload.totals.break_minutes} break\n` +
`#\n`;
return {
success: true,
csv: header + csv + "\n",
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.csv`,
};
}
// ── PDF ─────────────────────────────────────────────────────────────────────
export async function exportTimesheetPdf(
timesheetId: string,
): Promise<{ success: boolean; pdf?: Uint8Array; filename?: string; error?: string }> {
const payload = await loadExportPayload(timesheetId);
if (!payload) return { success: false, error: "Not authorized or not found" };
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const pageSize: [number, number] = [612, 792]; // US Letter
const margin = 40;
const lineHeight = 14;
let page = pdf.addPage(pageSize);
let cursor = pageSize[1] - margin;
function ensureRoom(lines = 1) {
if (cursor - lines * lineHeight < margin) {
page = pdf.addPage(pageSize);
cursor = pageSize[1] - margin;
}
}
function drawText(text: string, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) {
const size = opts.size ?? 10;
const f = opts.bold ? fontBold : font;
page.drawText(text, {
x: margin,
y: cursor - size,
size,
font: f,
color: opts.color ? rgb(...opts.color) : rgb(0, 0, 0),
});
cursor -= size + 4;
}
// Header
drawText(payload.brand_name, { bold: true, size: 16 });
drawText(`Timesheet — ${payload.worker_name} (${payload.worker_role})`, {
size: 12,
});
drawText(
`Period: ${payload.period_start}${payload.period_end} · Status: ${payload.status}`,
);
if (payload.reviewer) {
drawText(`Approver: ${payload.reviewer} · Approved: ${payload.approved_at}`);
}
cursor -= 4;
drawText("Totals", { bold: true });
drawText(
`Entries: ${payload.totals.entry_count} · ` +
`Total: ${formatHM(payload.totals.total_minutes)} · ` +
`Regular: ${formatHM(payload.totals.regular_minutes)} · ` +
`OT: ${formatHM(payload.totals.overtime_minutes)} · ` +
`Break: ${formatHM(payload.totals.break_minutes)}`,
);
cursor -= 8;
// Entries
drawText("Entries", { bold: true });
for (const r of payload.rows) {
ensureRoom(3);
const tag = r.entry_kind === "manual" ? "[manual] " : "";
const gps = r.gps_verified ? "📍" : "—";
drawText(
`${r.date} ${tag}${r.task} ${gps} ${r.clock_in}${r.clock_out} ` +
`(${formatHM(r.total_minutes)})`,
{ size: 9 },
);
if (r.notes) drawText(` notes: ${r.notes}`, { size: 8, color: [0.4, 0.4, 0.4] });
if (r.manual_reason)
drawText(` reason: ${r.manual_reason}`, {
size: 8,
color: [0.4, 0.4, 0.4],
});
}
cursor -= 24;
ensureRoom(2);
drawText("Signatures", { bold: true });
drawText("Employee: ____________________________________ Date: ______________");
cursor -= 18;
drawText("Supervisor: __________________________________ Date: ______________");
cursor -= 28;
ensureRoom(2);
drawText(
`Generated ${new Date().toISOString()} · audit id: ${timesheetId.slice(0, 8)}`,
{ size: 7, color: [0.5, 0.5, 0.5] },
);
const pdfBytes = await pdf.save();
return {
success: true,
pdf: pdfBytes,
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.pdf`,
};
}
// ── Loader ──────────────────────────────────────────────────────────────────
async function loadExportPayload(
timesheetId: string,
): Promise<ExportPayload | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
return withBrand(adminUser.brand_id ?? "", async (db) => {
const [ts] = await db
.select({
id: timeTrackingTimesheets.id,
brand_id: timeTrackingTimesheets.brandId,
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
period_start: timeTrackingTimesheets.periodStart,
period_end: timeTrackingTimesheets.periodEnd,
status: timeTrackingTimesheets.status,
total_minutes: timeTrackingTimesheets.totalMinutes,
regular_minutes: timeTrackingTimesheets.regularMinutes,
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
break_minutes: timeTrackingTimesheets.breakMinutes,
entry_count: timeTrackingTimesheets.entryCount,
approved_at: timeTrackingTimesheets.reviewedAt,
reviewer_name: fieldWorkers.name,
})
.from(timeTrackingTimesheets)
.leftJoin(
fieldWorkers,
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
)
.where(eq(timeTrackingTimesheets.id, timesheetId))
.limit(1);
if (!ts) return null;
if (
adminUser.role !== "platform_admin" &&
adminUser.brand_id !== ts.brand_id
)
return null;
const [worker] = await db
.select()
.from(fieldWorkers)
.where(eq(fieldWorkers.id, ts.field_worker_id))
.limit(1);
if (!worker) return null;
const entries = await db
.select()
.from(timeTrackingLogs)
.where(eq(timeTrackingLogs.fieldWorkerId, ts.field_worker_id))
.orderBy(timeTrackingLogs.clockIn);
const rows: ExportRow[] = entries
.filter(
(e) =>
e.clockIn >= new Date(ts.period_start) &&
e.clockIn <= endOfDay(ts.period_end),
)
.map((e) => {
const totalMin =
e.clockOut
? Math.max(
0,
Math.round(
(e.clockOut.getTime() - e.clockIn.getTime()) / 60000,
) - e.lunchBreakMinutes,
)
: 0;
return {
date: e.clockIn.toISOString().slice(0, 10),
worker_name: worker.name,
worker_role: worker.role,
task: e.taskName,
entry_kind: e.entryKind as "clock" | "manual",
clock_in: e.clockIn.toISOString().slice(11, 19),
clock_out: e.clockOut ? e.clockOut.toISOString().slice(11, 19) : "",
break_minutes: e.lunchBreakMinutes,
total_minutes: totalMin,
gps_verified: e.gpsVerified,
manual_reason: e.manualReason,
notes: e.notes,
};
});
return {
brand_name: adminUser.brand_slug ?? "Brand",
worker_name: worker.name,
worker_role: worker.role,
period_start: ts.period_start,
period_end: ts.period_end,
status: ts.status,
reviewer: ts.reviewer_name,
approved_at: ts.approved_at ? ts.approved_at.toISOString() : null,
rows,
totals: {
entry_count: ts.entry_count,
total_minutes: ts.total_minutes,
regular_minutes: ts.regular_minutes,
overtime_minutes: ts.overtime_minutes,
break_minutes: ts.break_minutes,
},
};
});
}
function endOfDay(iso: string): Date {
const d = new Date(iso);
d.setHours(23, 59, 59, 999);
return d;
}
function formatHM(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${m}m`;
}