/** * Time Tracking — Timesheets server actions. * * Cycle 12 (Phase 2): per-worker pay-period timesheets with a Draft → * Submitted → Approved/Locked workflow. Only `platform_admin` / * `brand_admin` can unlock a locked timesheet, and that requires a * mandatory audit note. * * Authorisation model: * - platform_admin / brand_admin — full access across their brand * - supervisor (field_workers.role = 'supervisor') — read + approve * timesheets for assigned supervisees only * - worker (field_workers.role IN ('worker','driver')) — can only * read their own timesheets and submit (draft → submitted); cannot * approve / unlock * * Concurrency: * - All mutations run inside a single `withTx` block so the timesheet * status + the audit row + the recompute RPC commit atomically. */ "use server"; import { and, desc, eq, gte, inArray, lte, sql, type SQL, } from "drizzle-orm"; import { z } from "zod"; import { getAdminUser } from "@/lib/admin-permissions"; import { getPool, withTx } from "@/lib/db"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { timeTrackingLogs, timeTrackingTimesheets, timeTrackingSupervisorAssignments, timeTrackingAuditLog, type TimesheetStatus, } from "@/db/schema/time-tracking"; import { fieldWorkers } from "@/db/schema/field-workers"; import { logTimeTrackingEventInTx, } from "@/lib/time-tracking-audit"; import { payPeriodForDate, toIsoDate } from "@/lib/time-tracking/period"; // ── Period math ──────────────────────────────────────────────────────────── // `payPeriodForDate` / `toIsoDate` live in `@/lib/time-tracking/period` // so client components can use them too — server-action files // ("use server") can only export async functions. // ── Types ────────────────────────────────────────────────────────────────── export type TimesheetEntry = { id: string; brand_id: string; field_worker_id: string; worker_name: string; task_id: string | null; task_name: string; clock_in: string; clock_out: string | null; lunch_break_minutes: number; notes: string | null; submitted_via: string; entry_kind: "clock" | "manual"; manual_reason: string | null; gps_verified: boolean; clock_in_lat: number | null; clock_in_lng: number | null; clock_in_accuracy_m: number | null; clock_out_lat: number | null; clock_out_lng: number | null; clock_out_accuracy_m: number | null; edited_at: string | null; edited_by_label: string | null; edit_reason: string | null; total_minutes: number; }; export type TimesheetSummary = { id: string; brand_id: string; field_worker_id: string; worker_name: string; worker_role: string; period_start: string; period_end: string; status: TimesheetStatus; total_minutes: number; regular_minutes: number; overtime_minutes: number; break_minutes: number; entry_count: number; daily_totals: Record; submitted_at: string | null; submitted_notes: string | null; reviewed_at: string | null; reviewed_by_label: string | null; reviewer_comment: string | null; rejected_at: string | null; rejection_reason: string | null; locked_at: string | null; unlocked_at: string | null; unlock_audit_note: string | null; updated_at: string; }; export type AuditEntry = { id: string; actor_id: string | null; actor_label: string; actor_kind: string; action: string; entity_type: string; entity_id: string | null; details: Record; created_at: string; }; // ── Auth helpers ─────────────────────────────────────────────────────────── type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker"; type CallerContext = { adminUser: NonNullable>>; kind: CallerKind; brandId: string; /** When kind === 'supervisor', the field_worker row id of the supervisor. */ supervisorFieldWorkerId: string | null; }; /** * Resolve the caller's role + brand context. Admins (platform/brand) are * detected by `admin_users.role`. Supervisors are detected by matching * `field_workers.role = 'supervisor'` for the admin's email. */ async function resolveCaller(brandId?: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) return null; const resolvedBrandId = brandId ?? adminUser.brand_id ?? null; if (!resolvedBrandId) return null; if (adminUser.role === "platform_admin") { return { adminUser, kind: "platform_admin", brandId: resolvedBrandId, supervisorFieldWorkerId: null, }; } if (adminUser.role === "brand_admin") { return { adminUser, kind: "brand_admin", brandId: resolvedBrandId, supervisorFieldWorkerId: null, }; } // store_employee or other: lookup as supervisor via email match return withPlatformAdmin(async (db) => { const fw = await db .select() .from(fieldWorkers) .where( and( eq(fieldWorkers.brandId, adminUser.brand_id ?? ""), sql`LOWER(${fieldWorkers.name}) = LOWER(${adminUser.email ?? ""})`, eq(fieldWorkers.role, "supervisor"), ), ) .limit(1); if (fw.length > 0) { return { adminUser, kind: "supervisor" as const, brandId: resolvedBrandId, supervisorFieldWorkerId: fw[0].id, }; } return { adminUser, kind: "worker" as const, brandId: resolvedBrandId, supervisorFieldWorkerId: null, }; }); } // ── List timesheets ──────────────────────────────────────────────────────── export async function listTimesheets( brandId: string, options: { status?: TimesheetStatus | "all"; workerId?: string; periodStart?: string; // YYYY-MM-DD periodEnd?: string; limit?: number; offset?: number; } = {}, ): Promise<{ rows: TimesheetSummary[]; total: number }> { const caller = await resolveCaller(brandId); if (!caller) return { rows: [], total: 0 }; return withBrand(caller.brandId, async (db) => { const conds: SQL[] = [eq(timeTrackingTimesheets.brandId, caller.brandId)]; if (options.status && options.status !== "all") { conds.push( eq(timeTrackingTimesheets.status, options.status as TimesheetStatus), ); } if (options.workerId) { conds.push(eq(timeTrackingTimesheets.fieldWorkerId, options.workerId)); } if (options.periodStart) { conds.push(gte(timeTrackingTimesheets.periodStart, options.periodStart)); } if (options.periodEnd) { conds.push(lte(timeTrackingTimesheets.periodEnd, options.periodEnd)); } // Supervisor scoping: only their assigned supervisees if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { const assigned = await db .select({ id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId, }) .from(timeTrackingSupervisorAssignments) .where( eq( timeTrackingSupervisorAssignments.supervisorFieldWorkerId, caller.supervisorFieldWorkerId, ), ); const ids = assigned.map((a) => a.id); if (ids.length === 0) return { rows: [], total: 0 }; conds.push(inArray(timeTrackingTimesheets.fieldWorkerId, ids)); } const limit = Math.min(options.limit ?? 100, 500); const offset = Math.max(options.offset ?? 0, 0); const rows = await db .select({ id: timeTrackingTimesheets.id, brand_id: timeTrackingTimesheets.brandId, field_worker_id: timeTrackingTimesheets.fieldWorkerId, worker_name: fieldWorkers.name, worker_role: fieldWorkers.role, 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, daily_totals: timeTrackingTimesheets.dailyTotals, submitted_at: timeTrackingTimesheets.submittedAt, submitted_notes: timeTrackingTimesheets.submittedNotes, reviewed_at: timeTrackingTimesheets.reviewedAt, reviewer_comment: timeTrackingTimesheets.reviewerComment, rejected_at: timeTrackingTimesheets.rejectedAt, rejection_reason: timeTrackingTimesheets.rejectionReason, locked_at: timeTrackingTimesheets.lockedAt, unlocked_at: timeTrackingTimesheets.unlockedAt, unlock_audit_note: timeTrackingTimesheets.unlockAuditNote, updated_at: timeTrackingTimesheets.updatedAt, }) .from(timeTrackingTimesheets) .leftJoin( fieldWorkers, eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId), ) .where(and(...conds)) .orderBy(desc(timeTrackingTimesheets.periodStart)) .limit(limit) .offset(offset); const totalRes = await db .select({ count: sql`COUNT(*)::int` }) .from(timeTrackingTimesheets) .where(and(...conds)); return { rows: rows.map((r) => rowToSummary({ ...r, daily_totals: (r.daily_totals ?? {}) as Record, reviewed_by_label: null, }), ), total: totalRes[0]?.count ?? 0, }; }); } function rowToSummary(r: { id: string; brand_id: string; field_worker_id: string; worker_name: string | null; worker_role: string | null; period_start: string; period_end: string; status: TimesheetStatus; total_minutes: number; regular_minutes: number; overtime_minutes: number; break_minutes: number; entry_count: number; daily_totals: Record | null; submitted_at: Date | null; submitted_notes: string | null; reviewed_at: Date | null; reviewer_comment: string | null; rejected_at: Date | null; rejection_reason: string | null; locked_at: Date | null; unlocked_at: Date | null; unlock_audit_note: string | null; updated_at: Date; reviewed_by_label: string | null; }): TimesheetSummary { return { id: r.id, brand_id: r.brand_id, field_worker_id: r.field_worker_id, worker_name: r.worker_name ?? "(unknown)", worker_role: r.worker_role ?? "worker", period_start: r.period_start, period_end: r.period_end, status: r.status, total_minutes: r.total_minutes, regular_minutes: r.regular_minutes, overtime_minutes: r.overtime_minutes, break_minutes: r.break_minutes, entry_count: r.entry_count, daily_totals: r.daily_totals ?? {}, submitted_at: r.submitted_at ? r.submitted_at.toISOString() : null, submitted_notes: r.submitted_notes, reviewed_at: r.reviewed_at ? r.reviewed_at.toISOString() : null, reviewed_by_label: r.reviewed_by_label, reviewer_comment: r.reviewer_comment, rejected_at: r.rejected_at ? r.rejected_at.toISOString() : null, rejection_reason: r.rejection_reason, locked_at: r.locked_at ? r.locked_at.toISOString() : null, unlocked_at: r.unlocked_at ? r.unlocked_at.toISOString() : null, unlock_audit_note: r.unlock_audit_note, updated_at: r.updated_at.toISOString(), }; } // ── Get a single timesheet (with entries + audit) ───────────────────────── export async function getTimesheet( timesheetId: string, ): Promise<{ summary: TimesheetSummary; entries: TimesheetEntry[]; audit: AuditEntry[]; } | null> { const caller = await resolveCaller(); if (!caller) return null; return withBrand(caller.brandId, async (db) => { const [row] = await db .select({ id: timeTrackingTimesheets.id, brand_id: timeTrackingTimesheets.brandId, field_worker_id: timeTrackingTimesheets.fieldWorkerId, worker_name: fieldWorkers.name, worker_role: fieldWorkers.role, 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, daily_totals: timeTrackingTimesheets.dailyTotals, submitted_at: timeTrackingTimesheets.submittedAt, submitted_notes: timeTrackingTimesheets.submittedNotes, reviewed_at: timeTrackingTimesheets.reviewedAt, reviewer_comment: timeTrackingTimesheets.reviewerComment, rejected_at: timeTrackingTimesheets.rejectedAt, rejection_reason: timeTrackingTimesheets.rejectionReason, locked_at: timeTrackingTimesheets.lockedAt, unlocked_at: timeTrackingTimesheets.unlockedAt, unlock_audit_note: timeTrackingTimesheets.unlockAuditNote, updated_at: timeTrackingTimesheets.updatedAt, }) .from(timeTrackingTimesheets) .leftJoin( fieldWorkers, eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId), ) .where( and( eq(timeTrackingTimesheets.id, timesheetId), eq(timeTrackingTimesheets.brandId, caller.brandId), ), ) .limit(1); if (!row) return null; // Auth scope check for supervisors if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { const assigned = await db .select({ id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId, }) .from(timeTrackingSupervisorAssignments) .where( and( eq( timeTrackingSupervisorAssignments.supervisorFieldWorkerId, caller.supervisorFieldWorkerId, ), eq( timeTrackingSupervisorAssignments.superviseeFieldWorkerId, row.field_worker_id, ), ), ) .limit(1); if (assigned.length === 0) return null; } // Entries const entryRows = await db .select({ id: timeTrackingLogs.id, brand_id: timeTrackingLogs.brandId, field_worker_id: timeTrackingLogs.fieldWorkerId, worker_name: fieldWorkers.name, task_id: timeTrackingLogs.taskId, task_name: timeTrackingLogs.taskName, clock_in: timeTrackingLogs.clockIn, clock_out: timeTrackingLogs.clockOut, lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes, notes: timeTrackingLogs.notes, submitted_via: timeTrackingLogs.submittedVia, entry_kind: timeTrackingLogs.entryKind, manual_reason: timeTrackingLogs.manualReason, gps_verified: timeTrackingLogs.gpsVerified, clock_in_lat: timeTrackingLogs.clockInLat, clock_in_lng: timeTrackingLogs.clockInLng, clock_in_accuracy_m: timeTrackingLogs.clockInAccuracyM, clock_out_lat: timeTrackingLogs.clockOutLat, clock_out_lng: timeTrackingLogs.clockOutLng, clock_out_accuracy_m: timeTrackingLogs.clockOutAccuracyM, edited_at: timeTrackingLogs.editedAt, edit_reason: timeTrackingLogs.editReason, }) .from(timeTrackingLogs) .leftJoin( fieldWorkers, eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId), ) .where( and( eq(timeTrackingLogs.brandId, caller.brandId), eq(timeTrackingLogs.fieldWorkerId, row.field_worker_id), gte(timeTrackingLogs.clockIn, new Date(row.period_start)), lte(timeTrackingLogs.clockIn, endOfDay(row.period_end)), ), ) .orderBy(timeTrackingLogs.clockIn); const entryIds = entryRows.map((e) => e.id); const auditRows = entryIds.length ? await db .select() .from(timeTrackingAuditLog) .where( and( eq(timeTrackingAuditLog.brandId, caller.brandId), sql`(${timeTrackingAuditLog.entityType} = 'timesheet' AND ${timeTrackingAuditLog.entityId} = ${timesheetId}) OR (${timeTrackingAuditLog.entityType} = 'log' AND ${timeTrackingAuditLog.entityId} IN (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)}))`, ), ) .orderBy(desc(timeTrackingAuditLog.createdAt)) .limit(200) : []; return { summary: rowToSummary({ ...row, daily_totals: (row.daily_totals ?? {}) as Record, reviewed_by_label: null, }), entries: entryRows.map( (e): TimesheetEntry => ({ id: e.id, brand_id: e.brand_id, field_worker_id: e.field_worker_id, worker_name: e.worker_name ?? "(unknown)", task_id: e.task_id, task_name: e.task_name, clock_in: e.clock_in.toISOString(), clock_out: e.clock_out ? e.clock_out.toISOString() : null, lunch_break_minutes: e.lunch_break_minutes, notes: e.notes, submitted_via: e.submitted_via, entry_kind: e.entry_kind, manual_reason: e.manual_reason, gps_verified: e.gps_verified, clock_in_lat: e.clock_in_lat, clock_in_lng: e.clock_in_lng, clock_in_accuracy_m: e.clock_in_accuracy_m, clock_out_lat: e.clock_out_lat, clock_out_lng: e.clock_out_lng, clock_out_accuracy_m: e.clock_out_accuracy_m, edited_at: e.edited_at ? e.edited_at.toISOString() : null, edited_by_label: null, edit_reason: e.edit_reason, total_minutes: computeMinutes( e.clock_in, e.clock_out, e.lunch_break_minutes, ), }), ), audit: auditRows.map((a) => ({ id: a.id, actor_id: a.actorId, actor_label: a.actorLabel, actor_kind: a.actorKind, action: a.action, entity_type: a.entityType, entity_id: a.entityId, details: (a.details as Record) ?? {}, created_at: a.createdAt.toISOString(), })), }; }); } function endOfDay(iso: string): Date { const d = new Date(iso); d.setHours(23, 59, 59, 999); return d; } function computeMinutes( clockIn: Date, clockOut: Date | null, lunchMinutes: number, ): number { if (!clockOut) return 0; return Math.max( 0, Math.round((clockOut.getTime() - clockIn.getTime()) / 60000) - lunchMinutes, ); } // ── Ensure the timesheet exists for a (worker, period) ──────────────────── export async function ensureTimesheetForPeriod( brandId: string, workerId: string, periodStart: string, periodEnd: string, ): Promise { const caller = await resolveCaller(brandId); if (!caller) throw new Error("Not authenticated"); const pool = getPool(); const res = await pool.query<{ get_or_create_timesheet: string }>( "SELECT get_or_create_timesheet($1::uuid, $2::uuid, $3::date, $4::date) AS get_or_create_timesheet", [brandId, workerId, periodStart, periodEnd], ); return res.rows[0]?.get_or_create_timesheet ?? ""; } // ── Submit, approve, reject, unlock ──────────────────────────────────────── const SubmitSchema = z.object({ timesheetId: z.string().uuid(), notes: z.string().max(1000).optional(), }); export async function submitTimesheet( timesheetId: string, notes?: string, ): Promise<{ success: boolean; error?: string }> { const caller = await resolveCaller(); if (!caller) return { success: false, error: "Not authenticated" }; const parsed = SubmitSchema.safeParse({ timesheetId, notes }); if (!parsed.success) return { success: false, error: "Invalid input" }; return withTx(async (client) => { const ts = await client.query<{ brand_id: string; field_worker_id: string; status: string; }>( `SELECT brand_id, field_worker_id, status FROM time_tracking_timesheets WHERE id = $1 FOR UPDATE`, [timesheetId], ); if (ts.rows.length === 0) return { success: false, error: "Not found" }; const t = ts.rows[0]; if (t.status !== "draft" && t.status !== "rejected") { return { success: false, error: `Cannot submit a timesheet in status '${t.status}'`, }; } await client.query( `UPDATE time_tracking_timesheets SET status = 'submitted', submitted_at = NOW(), submitted_by = $2, submitted_notes = $3, rejected_at = NULL, rejected_by = NULL, rejection_reason = NULL, updated_at = NOW() WHERE id = $1`, [timesheetId, caller.adminUser.id, notes ?? null], ); await logTimeTrackingEventInTx(client, { brandId: t.brand_id, actorId: caller.adminUser.id, actorLabel: caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", action: "timesheet.submit", entityType: "timesheet", entityId: timesheetId, details: { notes: notes ?? null }, }); return { success: true }; }); } const ApprovalSchema = z.object({ timesheetId: z.string().uuid(), comment: z.string().max(2000).optional(), }); export async function approveTimesheet( timesheetId: string, comment?: string, ): Promise<{ success: boolean; error?: string }> { const caller = await resolveCaller(); if (!caller) return { success: false, error: "Not authenticated" }; if ( caller.kind !== "platform_admin" && caller.kind !== "brand_admin" && caller.kind !== "supervisor" ) { return { success: false, error: "Not authorized" }; } const parsed = ApprovalSchema.safeParse({ timesheetId, comment }); if (!parsed.success) return { success: false, error: "Invalid input" }; return withTx(async (client) => { const ts = await client.query<{ brand_id: string; field_worker_id: string; status: string; }>( `SELECT brand_id, field_worker_id, status FROM time_tracking_timesheets WHERE id = $1 FOR UPDATE`, [timesheetId], ); if (ts.rows.length === 0) return { success: false, error: "Not found" }; const t = ts.rows[0]; // Supervisor must be assigned to this worker if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { const ok = await client.query( `SELECT 1 FROM time_tracking_supervisor_assignments WHERE supervisor_field_worker_id = $1 AND supervisee_field_worker_id = $2 LIMIT 1`, [caller.supervisorFieldWorkerId, t.field_worker_id], ); if (ok.rows.length === 0) return { success: false, error: "Not authorized for this worker" }; } if (t.status !== "submitted") { return { success: false, error: `Only submitted timesheets can be approved (status='${t.status}')`, }; } const now = new Date(); await client.query( `UPDATE time_tracking_timesheets SET status = 'approved', reviewed_at = $2, reviewed_by = $3, reviewer_comment = $4, locked_at = $2, locked_by = $3, updated_at = NOW() WHERE id = $1`, [timesheetId, now, caller.adminUser.id, comment ?? null], ); await logTimeTrackingEventInTx(client, { brandId: t.brand_id, actorId: caller.adminUser.id, actorLabel: caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", action: "timesheet.approve", entityType: "timesheet", entityId: timesheetId, details: { comment: comment ?? null }, }); // Recompute totals so the locked snapshot is accurate await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]); return { success: true }; }); } const RejectSchema = z.object({ timesheetId: z.string().uuid(), reason: z.string().min(3).max(2000), }); export async function rejectTimesheet( timesheetId: string, reason: string, ): Promise<{ success: boolean; error?: string }> { const caller = await resolveCaller(); if (!caller) return { success: false, error: "Not authenticated" }; if ( caller.kind !== "platform_admin" && caller.kind !== "brand_admin" && caller.kind !== "supervisor" ) { return { success: false, error: "Not authorized" }; } const parsed = RejectSchema.safeParse({ timesheetId, reason }); if (!parsed.success) return { success: false, error: "Reason is required" }; return withTx(async (client) => { const ts = await client.query<{ brand_id: string; field_worker_id: string; status: string; }>( `SELECT brand_id, field_worker_id, status FROM time_tracking_timesheets WHERE id = $1 FOR UPDATE`, [timesheetId], ); if (ts.rows.length === 0) return { success: false, error: "Not found" }; const t = ts.rows[0]; if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { const ok = await client.query( `SELECT 1 FROM time_tracking_supervisor_assignments WHERE supervisor_field_worker_id = $1 AND supervisee_field_worker_id = $2 LIMIT 1`, [caller.supervisorFieldWorkerId, t.field_worker_id], ); if (ok.rows.length === 0) return { success: false, error: "Not authorized" }; } if (t.status !== "submitted") { return { success: false, error: `Only submitted timesheets can be rejected (status='${t.status}')`, }; } await client.query( `UPDATE time_tracking_timesheets SET status = 'rejected', rejected_at = NOW(), rejected_by = $2, rejection_reason = $3, locked_at = NULL, locked_by = NULL, updated_at = NOW() WHERE id = $1`, [timesheetId, caller.adminUser.id, reason], ); await logTimeTrackingEventInTx(client, { brandId: t.brand_id, actorId: caller.adminUser.id, actorLabel: caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", action: "timesheet.reject", entityType: "timesheet", entityId: timesheetId, details: { reason }, }); return { success: true }; }); } const UnlockSchema = z.object({ timesheetId: z.string().uuid(), auditNote: z.string().min(5).max(2000), }); export async function unlockTimesheet( timesheetId: string, auditNote: string, ): Promise<{ success: boolean; error?: string }> { const caller = await resolveCaller(); if (!caller) return { success: false, error: "Not authenticated" }; // Only platform_admin or brand_admin can unlock a locked timesheet. if (caller.kind !== "platform_admin" && caller.kind !== "brand_admin") { return { success: false, error: "Only admins can unlock a locked timesheet", }; } const parsed = UnlockSchema.safeParse({ timesheetId, auditNote }); if (!parsed.success) return { success: false, error: "An audit note of at least 5 characters is required", }; return withTx(async (client) => { const ts = await client.query<{ brand_id: string; field_worker_id: string; status: string; locked_at: Date | null; }>( `SELECT brand_id, field_worker_id, status, locked_at FROM time_tracking_timesheets WHERE id = $1 FOR UPDATE`, [timesheetId], ); if (ts.rows.length === 0) return { success: false, error: "Not found" }; const t = ts.rows[0]; if (!t.locked_at) { return { success: false, error: "Timesheet is not currently locked" }; } await client.query( `UPDATE time_tracking_timesheets SET status = 'draft', locked_at = NULL, locked_by = NULL, unlocked_at = NOW(), unlocked_by = $2, unlock_audit_note = $3, updated_at = NOW() WHERE id = $1`, [timesheetId, caller.adminUser.id, auditNote], ); await logTimeTrackingEventInTx(client, { brandId: t.brand_id, actorId: caller.adminUser.id, actorLabel: caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", action: "timesheet.unlock", entityType: "timesheet", entityId: timesheetId, details: { audit_note: auditNote }, }); return { success: true }; }); } // ── Helpers for client components ────────────────────────────────────────── /** Compute and return the [start, end] of the current pay period for a brand. */ export async function getCurrentPayPeriod( brandId: string, ): Promise<{ start: string; end: string }> { const { getTimeTrackingSettings } = await import("./index"); const settings = await getTimeTrackingSettings(brandId); const { start, end } = payPeriodForDate(new Date(), settings); return { start: toIsoDate(start), end: toIsoDate(end) }; }