9e6accb3df
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.
326 lines
11 KiB
TypeScript
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`;
|
|
} |