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.
This commit is contained in:
@@ -0,0 +1,909 @@
|
||||
/**
|
||||
* 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user