/** * 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 { 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`; }