Files
route-commerce/src/actions/time-tracking/timesheets.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

909 lines
29 KiB
TypeScript

/**
* 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<string, number>;
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<string, unknown>;
created_at: string;
};
// ── Auth helpers ───────────────────────────────────────────────────────────
type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker";
type CallerContext = {
adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>>;
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<CallerContext | null> {
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<number>`COUNT(*)::int` })
.from(timeTrackingTimesheets)
.where(and(...conds));
return {
rows: rows.map((r) =>
rowToSummary({
...r,
daily_totals: (r.daily_totals ?? {}) as Record<string, number>,
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<string, number> | 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<string, number>,
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<string, unknown>) ?? {},
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<string> {
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) };
}