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.
909 lines
29 KiB
TypeScript
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) };
|
|
} |