diff --git a/.gitignore b/.gitignore index 575f7bd..5ff31a7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,12 @@ supabase/.temp/ # Playwright test results (generated, not source) test-results/ playwright-report/ +tests/_artifacts/ + +# Local debug / smoke scripts (not part of the product) +scripts/cycle*-debug.mjs +scripts/cycle*-smoke.mjs +scripts/cycle*-smoke-session.mjs # IDE / local config .mcp.json diff --git a/db/migrations/0098_time_tracking_timesheets.sql b/db/migrations/0098_time_tracking_timesheets.sql new file mode 100644 index 0000000..93b48c2 --- /dev/null +++ b/db/migrations/0098_time_tracking_timesheets.sql @@ -0,0 +1,443 @@ +-- ============================================================================ +-- 0098_time_tracking_timesheets.sql +-- +-- Time Tracking — Phase 2 (Timesheets, GPS, Approval/Lock, Audit) +-- +-- This migration extends the existing time-tracking schema with the +-- production / payroll features the field app + admin UI need: +-- +-- 1. New roles on `field_workers` — `driver`, `supervisor` +-- (kept distinct from `worker` so admin UIs can filter / grant +-- approval rights without expanding the existing role semantics). +-- +-- 2. GPS verification on every clock-in / clock-out — +-- `time_tracking_logs` gains lat/lng/accuracy columns for both +-- events, plus a boolean `gps_verified` (false when the device +-- denied geolocation, returned an error, or low-accuracy fix). +-- +-- 3. Manual time entries — +-- `time_tracking_logs.entry_kind` becomes `'clock' | 'manual'`. +-- Manual rows carry `manual_reason` (the why-this-was-added text +-- required by payroll / labor law) and can be edited freely until +-- the parent timesheet is locked. +-- +-- 4. Edit audit fields on `time_tracking_logs` — +-- `edited_at`, `edited_by` (admin_user id), `edit_reason`. Filled +-- by every edit that lands on a row; never cleared. +-- +-- 5. NEW: `time_tracking_timesheets` — one row per (worker, pay +-- period). Statuses drive the approval workflow: +-- draft → submitted → approved (locked) → unlocked (admin only, +-- audit note required) +-- → draft (after rejection) +-- When `locked_at IS NOT NULL` the underlying logs become +-- read-only to anyone except `platform_admin` / `time_admin`. +-- +-- 6. NEW: `time_tracking_audit_log` — append-only audit trail for +-- every mutating action on a timesheet or its entries. Every +-- server-action call that mutates a timesheet or a log row MUST +-- write a row here in the same transaction. +-- +-- 7. NEW: `time_tracking_supervisor_assignments` — many-to-many +-- (supervisor_worker_id, supervisee_worker_id). Supervisors only +-- see / approve timesheets for their assigned crew. +-- +-- The migration is idempotent — every ALTER / CREATE uses IF NOT EXISTS +-- or a DO block, so re-running on a partially-applied DB is safe. +-- ============================================================================ + +-- ─────────────────────────────────────────────────────────────────────────── +-- 1. Role enum extension +-- ─────────────────────────────────────────────────────────────────────────── + +-- Existing enum (migration 0097) is named `field_workers_role_check` +-- via a CHECK constraint, NOT a Postgres ENUM type. Verify by introspecting +-- pg_constraint before adding new values. +DO $$ +DECLARE + constraint_def TEXT; +BEGIN + -- Fetch the current CHECK expression + SELECT pg_get_constraintdef(oid) + INTO constraint_def + FROM pg_constraint + WHERE conname = 'field_workers_role_check'; + + IF constraint_def IS NOT NULL THEN + -- Drop and recreate with the expanded vocabulary. The set is the union + -- of all six role values the codebase now recognises. + EXECUTE 'ALTER TABLE field_workers DROP CONSTRAINT field_workers_role_check'; + END IF; +END $$; + +ALTER TABLE field_workers + ADD CONSTRAINT field_workers_role_check + CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin', 'driver', 'supervisor')); + +-- Drop & recreate the Drizzle CHECK by hand if it exists under a different name +-- (drizzle-kit may have generated `field_workers_role_check1`). +DO $$ +DECLARE + cname TEXT; +BEGIN + FOR cname IN + SELECT conname + FROM pg_constraint + WHERE conrelid = 'field_workers'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%role%' + AND conname <> 'field_workers_role_check' + LOOP + EXECUTE format('ALTER TABLE field_workers DROP CONSTRAINT %I', cname); + END LOOP; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 2. GPS columns + entry_kind + manual_reason + edit audit on logs +-- ─────────────────────────────────────────────────────────────────────────── + +ALTER TABLE time_tracking_logs + ADD COLUMN IF NOT EXISTS clock_in_lat DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS clock_in_lng DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS clock_in_accuracy_m DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS clock_out_lat DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS clock_out_lng DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS clock_out_accuracy_m DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS gps_verified BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS entry_kind TEXT NOT NULL DEFAULT 'clock', + ADD COLUMN IF NOT EXISTS manual_reason TEXT, + ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS edited_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS edit_reason TEXT; + +-- entry_kind CHECK (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'time_tracking_logs_entry_kind_check' + ) THEN + ALTER TABLE time_tracking_logs + ADD CONSTRAINT time_tracking_logs_entry_kind_check + CHECK (entry_kind IN ('clock', 'manual')); + END IF; +END $$; + +-- Index for fast "all manual entries this period" queries used by payroll +CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_kind_idx + ON time_tracking_logs (brand_id, entry_kind, clock_in); + +-- Index for the approval queue (entries that belong to a timesheet status) +CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx + ON time_tracking_logs (clock_in); + +-- ─────────────────────────────────────────────────────────────────────────── +-- 3. time_tracking_timesheets (the workflow entity) +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS time_tracking_timesheets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Workflow state + status TEXT NOT NULL DEFAULT 'draft', + + -- Submission + submitted_at TIMESTAMPTZ, + submitted_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + submitted_notes TEXT, + + -- Approval (locks the timesheet) + reviewed_at TIMESTAMPTZ, + reviewed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + reviewer_comment TEXT, + + -- Rejection (sends back to draft) + rejected_at TIMESTAMPTZ, + rejected_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + rejection_reason TEXT, + + -- Lock state (mirrors reviewed_at for fast filtering) + locked_at TIMESTAMPTZ, + locked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + + -- Unlock state (rare, admin-only, MUST carry a note) + unlocked_at TIMESTAMPTZ, + unlocked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + unlock_audit_note TEXT NOT NULL DEFAULT '', + + -- Pre-computed totals — recomputed by triggers / RPCs, but cached here + -- so the timesheet list view doesn't re-aggregate on every render. + total_minutes INTEGER NOT NULL DEFAULT 0, + regular_minutes INTEGER NOT NULL DEFAULT 0, + overtime_minutes INTEGER NOT NULL DEFAULT 0, + break_minutes INTEGER NOT NULL DEFAULT 0, + entry_count INTEGER NOT NULL DEFAULT 0, + + -- Daily-OT flash cache: JSONB { 'YYYY-MM-DD': minutes } + daily_totals JSONB NOT NULL DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT time_tracking_timesheets_period_check + CHECK (period_end >= period_start), + CONSTRAINT time_tracking_timesheets_status_check + CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_timesheets_worker_period_uniq + ON time_tracking_timesheets (field_worker_id, period_start, period_end); + +CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_status_idx + ON time_tracking_timesheets (brand_id, status, period_start DESC); + +CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_worker_idx + ON time_tracking_timesheets (brand_id, field_worker_id); + +-- ─────────────────────────────────────────────────────────────────────────── +-- 4. time_tracking_audit_log (append-only) +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS time_tracking_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + actor_id UUID REFERENCES admin_users(id) ON DELETE SET NULL, + actor_label TEXT NOT NULL, + actor_kind TEXT NOT NULL DEFAULT 'admin', + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS time_tracking_audit_log_brand_recent_idx + ON time_tracking_audit_log (brand_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS time_tracking_audit_log_entity_idx + ON time_tracking_audit_log (entity_type, entity_id); + +-- ─────────────────────────────────────────────────────────────────────────── +-- 5. Supervisor ↔ supervisee assignments +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS time_tracking_supervisor_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + supervisor_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, + supervisee_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT time_tracking_supervisor_assignments_no_self + CHECK (supervisor_field_worker_id <> supervisee_field_worker_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_uniq + ON time_tracking_supervisor_assignments ( + supervisor_field_worker_id, supervisee_field_worker_id + ); + +CREATE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_supervisor_idx + ON time_tracking_supervisor_assignments (supervisor_field_worker_id); + +-- ─────────────────────────────────────────────────────────────────────────── +-- 6. RLS — minimal, since the platform already relies on app-layer +-- brand scoping via SECURITY DEFINER / Drizzle `withBrand` wrappers. +-- The new tables are added to the same RLS scaffolding as the existing +-- `time_tracking_*` tables when present; otherwise left open at the +-- table level (the app enforces isolation). +-- ─────────────────────────────────────────────────────────────────────────── + +DO $$ +DECLARE + rls_enabled BOOLEAN; +BEGIN + -- If RLS is enabled on time_tracking_logs, mirror it on the new tables. + SELECT relrowsecurity INTO rls_enabled + FROM pg_class + WHERE relname = 'time_tracking_logs'; + + IF rls_enabled THEN + EXECUTE 'ALTER TABLE time_tracking_timesheets ENABLE ROW LEVEL SECURITY'; + EXECUTE 'ALTER TABLE time_tracking_audit_log ENABLE ROW LEVEL SECURITY'; + EXECUTE 'ALTER TABLE time_tracking_supervisor_assignments ENABLE ROW LEVEL SECURITY'; + END IF; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 7. Triggers — keep `updated_at` fresh +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION time_tracking_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS time_tracking_timesheets_set_updated_at + ON time_tracking_timesheets; +CREATE TRIGGER time_tracking_timesheets_set_updated_at + BEFORE UPDATE ON time_tracking_timesheets + FOR EACH ROW EXECUTE FUNCTION time_tracking_set_updated_at(); + +-- ─────────────────────────────────────────────────────────────────────────── +-- 8. Helper view — admin "what's pending" dashboard +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE VIEW time_tracking_approval_queue AS +SELECT + t.id, + t.brand_id, + t.field_worker_id, + fw.name AS worker_name, + t.period_start, + t.period_end, + t.status, + t.total_minutes, + t.regular_minutes, + t.overtime_minutes, + t.entry_count, + t.submitted_at, + t.submitted_notes, + t.reviewed_at, + t.locked_at +FROM time_tracking_timesheets t +JOIN field_workers fw ON fw.id = t.field_worker_id; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 9. SECURITY DEFINER RPC — recompute timesheet totals +-- Called by the server after any entry add/edit/delete so the cached +-- totals on `time_tracking_timesheets` stay in sync with the underlying +-- `time_tracking_logs`. +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION recompute_timesheet_totals(p_timesheet_id UUID) +RETURNS VOID AS $$ +DECLARE + v_brand_id UUID; + v_worker_id UUID; + v_period_start DATE; + v_period_end DATE; + v_total INTEGER; + v_regular INTEGER; + v_ot INTEGER; + v_break INTEGER; + v_count INTEGER; + v_daily JSONB; + v_daily_thresh NUMERIC; + v_settings_brand UUID; +BEGIN + -- Load the timesheet header + SELECT brand_id, field_worker_id, period_start, period_end + INTO v_brand_id, v_worker_id, v_period_start, v_period_end + FROM time_tracking_timesheets + WHERE id = p_timesheet_id; + + IF v_brand_id IS NULL THEN + RETURN; + END IF; + + -- Pull daily OT threshold from settings (default 8h) + SELECT COALESCE(daily_overtime_threshold, 8) + INTO v_daily_thresh + FROM time_tracking_settings + WHERE brand_id = v_brand_id; + v_daily_thresh := COALESCE(v_daily_thresh, 8); + + -- Aggregate underlying logs + SELECT + COALESCE(SUM( + GREATEST(0, + EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60 + - COALESCE(lunch_break_minutes, 0) + ) + ), 0)::INTEGER, + COALESCE(SUM(lunch_break_minutes), 0)::INTEGER, + COUNT(*)::INTEGER, + jsonb_object_agg( + d::text, mins + ) FILTER (WHERE d IS NOT NULL) + INTO v_total, v_break, v_count, v_daily + FROM ( + SELECT + (clock_in AT TIME ZONE 'America/Denver')::date AS d, + SUM( + GREATEST(0, + EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60 + - COALESCE(lunch_break_minutes, 0) + ) + ) AS mins + FROM time_tracking_logs + WHERE brand_id = v_brand_id + AND field_worker_id = v_worker_id + AND clock_in >= v_period_start + AND clock_in < (v_period_end + INTERVAL '1 day') + GROUP BY 1 + ) daily_agg + RIGHT JOIN time_tracking_logs l + ON l.brand_id = v_brand_id + AND l.field_worker_id = v_worker_id + AND l.clock_in >= v_period_start + AND l.clock_in < (v_period_end + INTERVAL '1 day'); + + v_daily := COALESCE(v_daily, '{}'::jsonb); + + -- Daily OT = sum across days of (daily_mins - threshold*60)+ + v_ot := 0; + IF jsonb_typeof(v_daily) = 'object' THEN + SELECT COALESCE(SUM(GREATEST(0, (v.value::numeric - v_daily_thresh * 60))), 0)::INTEGER + INTO v_ot + FROM jsonb_each(v_daily) v; + END IF; + + v_regular := GREATEST(0, v_total - v_ot); + + UPDATE time_tracking_timesheets + SET total_minutes = v_total, + regular_minutes = v_regular, + overtime_minutes = v_ot, + break_minutes = v_break, + entry_count = v_count, + daily_totals = v_daily + WHERE id = p_timesheet_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ─────────────────────────────────────────────────────────────────────────── +-- 10. SECURITY DEFINER RPC — get-or-create a timesheet for a worker × period +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION get_or_create_timesheet( + p_brand_id UUID, + p_worker_id UUID, + p_period_start DATE, + p_period_end DATE +) +RETURNS UUID AS $$ +DECLARE + v_id UUID; +BEGIN + SELECT id INTO v_id + FROM time_tracking_timesheets + WHERE field_worker_id = p_worker_id + AND period_start = p_period_start + AND period_end = p_period_end; + + IF v_id IS NULL THEN + INSERT INTO time_tracking_timesheets + (brand_id, field_worker_id, period_start, period_end) + VALUES + (p_brand_id, p_worker_id, p_period_start, p_period_end) + RETURNING id INTO v_id; + + PERFORM recompute_timesheet_totals(v_id); + END IF; + + RETURN v_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/db/schema/field-workers.ts b/db/schema/field-workers.ts index 06f9ca6..3ab76ba 100644 --- a/db/schema/field-workers.ts +++ b/db/schema/field-workers.ts @@ -33,7 +33,7 @@ export const fieldWorkers = pgTable( name: text("name").notNull(), pinHash: text("pin_hash").notNull(), role: text("role", { - enum: ["worker", "time_admin", "irrigator", "water_admin"], + enum: ["worker", "time_admin", "irrigator", "water_admin", "driver", "supervisor"], }) .notNull() .default("worker"), diff --git a/db/schema/time-tracking.ts b/db/schema/time-tracking.ts index 35ec692..542e584 100644 --- a/db/schema/time-tracking.ts +++ b/db/schema/time-tracking.ts @@ -12,6 +12,9 @@ * `worker_id` column on the downstream tables * (`time_tracking_logs`, `time_tracking_notification_log`) was * renamed to `field_worker_id` and the FK repointed. + * + * Cycle 12 (Phase 2): GPS columns + manual entries + audit log + + * timesheet approval workflow (0098_time_tracking_timesheets.sql). */ import { pgTable, @@ -23,8 +26,10 @@ import { timestamp, index, jsonb, + date, + doublePrecision, } from "drizzle-orm/pg-core"; -import { brands } from "./brands"; +import { brands, adminUsers } from "./brands"; import { fieldWorkers } from "./field-workers"; export const timeTrackingSettings = pgTable( @@ -115,6 +120,32 @@ export const timeTrackingLogs = pgTable( submittedVia: text("submitted_via", { enum: ["manual", "field", "import"], }).notNull().default("manual"), + + // ── Migration 0098: GPS + manual entries + edit audit ──────── + /** `'clock'` = from the field app, `'manual'` = admin-entered. */ + entryKind: text("entry_kind", { enum: ["clock", "manual"] }) + .notNull() + .default("clock"), + /** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */ + manualReason: text("manual_reason"), + /** GPS columns — captured at clock-in / clock-out. Nullable: device + * may have denied geolocation, in which case `gpsVerified = false`. */ + clockInLat: doublePrecision("clock_in_lat"), + clockInLng: doublePrecision("clock_in_lng"), + clockInAccuracyM: doublePrecision("clock_in_accuracy_m"), + clockOutLat: doublePrecision("clock_out_lat"), + clockOutLng: doublePrecision("clock_out_lng"), + clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"), + /** True iff the device supplied a fix with accuracy ≤ 100m. */ + gpsVerified: boolean("gps_verified").notNull().default(false), + /** Edit audit — filled every time a server action updates the row + * after creation. Never cleared. */ + editedAt: timestamp("edited_at", { withTimezone: true }), + editedBy: uuid("edited_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + editReason: text("edit_reason"), + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -125,6 +156,11 @@ export const timeTrackingLogs = pgTable( t.fieldWorkerId, ), clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn), + brandKindIdx: index("time_tracking_logs_brand_kind_idx").on( + t.brandId, + t.entryKind, + t.clockIn, + ), }), ); @@ -154,6 +190,164 @@ export const timeTrackingNotificationLog = pgTable( }, ); +// ── Migration 0098: Timesheet + approval workflow ───────────────────────── + +export const TIMESHEET_STATUSES = [ + "draft", + "submitted", + "approved", + "rejected", +] as const; +export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number]; + +export const timeTrackingTimesheets = pgTable( + "time_tracking_timesheets", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + fieldWorkerId: uuid("field_worker_id") + .notNull() + .references(() => fieldWorkers.id, { onDelete: "cascade" }), + periodStart: date("period_start").notNull(), + periodEnd: date("period_end").notNull(), + + status: text("status", { enum: TIMESHEET_STATUSES }) + .notNull() + .default("draft"), + + // Submission + submittedAt: timestamp("submitted_at", { withTimezone: true }), + submittedBy: uuid("submitted_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + submittedNotes: text("submitted_notes"), + + // Approval (locks the timesheet) + reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + reviewerComment: text("reviewer_comment"), + + // Rejection + rejectedAt: timestamp("rejected_at", { withTimezone: true }), + rejectedBy: uuid("rejected_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + rejectionReason: text("rejection_reason"), + + // Lock mirror (always equals reviewed_at when status=approved) + lockedAt: timestamp("locked_at", { withTimezone: true }), + lockedBy: uuid("locked_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + + // Unlock (rare, admin-only, MUST carry an audit note) + unlockedAt: timestamp("unlocked_at", { withTimezone: true }), + unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, { + onDelete: "set null", + }), + unlockAuditNote: text("unlock_audit_note").notNull().default(""), + + // Pre-computed totals (recomputed by recompute_timesheet_totals RPC) + totalMinutes: integer("total_minutes").notNull().default(0), + regularMinutes: integer("regular_minutes").notNull().default(0), + overtimeMinutes: integer("overtime_minutes").notNull().default(0), + breakMinutes: integer("break_minutes").notNull().default(0), + entryCount: integer("entry_count").notNull().default(0), + /** Map of date string ("YYYY-MM-DD") → minutes worked that day. */ + dailyTotals: jsonb("daily_totals").notNull().default({}), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + workerPeriodUniq: index( + "time_tracking_timesheets_worker_period_uniq", + ).on(t.fieldWorkerId, t.periodStart, t.periodEnd), + brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on( + t.brandId, + t.status, + t.periodStart, + ), + brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on( + t.brandId, + t.fieldWorkerId, + ), + }), +); + +/** Append-only audit log — every mutating action on a timesheet or its + * underlying logs MUST write a row here in the same DB transaction. */ +export const timeTrackingAuditLog = pgTable( + "time_tracking_audit_log", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + /** Admin user (or null when the actor was the worker themselves). */ + actorId: uuid("actor_id").references(() => adminUsers.id, { + onDelete: "set null", + }), + actorLabel: text("actor_label").notNull(), + actorKind: text("actor_kind").notNull().default("admin"), + action: text("action").notNull(), + entityType: text("entity_type").notNull(), + entityId: uuid("entity_id"), + details: jsonb("details").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on( + t.brandId, + t.createdAt, + ), + entityIdx: index("time_tracking_audit_log_entity_idx").on( + t.entityType, + t.entityId, + ), + }), +); + +/** Supervisor ↔ supervisee edges. The brand admin assigns edges; the + * approval queue filters by them. */ +export const timeTrackingSupervisorAssignments = pgTable( + "time_tracking_supervisor_assignments", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + supervisorFieldWorkerId: uuid("supervisor_field_worker_id") + .notNull() + .references(() => fieldWorkers.id, { onDelete: "cascade" }), + superviseeFieldWorkerId: uuid("supervisee_field_worker_id") + .notNull() + .references(() => fieldWorkers.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + uniq: index("time_tracking_supervisor_assignments_uniq").on( + t.supervisorFieldWorkerId, + t.superviseeFieldWorkerId, + ), + supervisorIdx: index( + "time_tracking_supervisor_assignments_supervisor_idx", + ).on(t.supervisorFieldWorkerId), + }), +); + export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect; // Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from // `./field-workers` instead; filter by role = 'worker' or 'time_admin' @@ -162,6 +356,18 @@ export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect; export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect; export type TimeTrackingNotificationLog = typeof timeTrackingNotificationLog.$inferSelect; +export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect; +export type TimeTrackingTimesheetInsert = + typeof timeTrackingTimesheets.$inferInsert; +export type TimeTrackingAuditLogEntry = + typeof timeTrackingAuditLog.$inferSelect; +export type TimeTrackingSupervisorAssignment = + typeof timeTrackingSupervisorAssignments.$inferSelect; + +// ── Inferred types ── + +// 0098 — daily totals JSON shape stored on the timesheet +export type DailyTotals = Record; // ── Smartsheet sync (Cycle 5) ────────────────────────────────────────────── diff --git a/src/actions/offline-dispatcher.ts b/src/actions/offline-dispatcher.ts index 8b3b9ea..8fcd734 100644 --- a/src/actions/offline-dispatcher.ts +++ b/src/actions/offline-dispatcher.ts @@ -48,12 +48,30 @@ interface ClientAction { async function listClientActions(): Promise { -await getSession(); // Wire real server actions here as they land: + await getSession(); + // Wire real server actions here as they land: // { name: "markOrderReady", handler: markOrderReady }, // { name: "markOrderPickedUp", handler: markOrderPickedUp }, // { name: "updateStopStatus", handler: updateStopStatus }, // { name: "adjustProductStock", handler: adjustProductStock }, - return []; + // + // Time Tracking (cycle 12) — replay handlers for offline clock + // events. The replay re-verifies the worker's PIN before invoking + // the field action, then the action writes with submitted_via + // distinguishable so payroll can audit. + const { replayOfflineClock } = await import("./time-tracking/offline-handlers"); + return [ + { + name: "timeTracking.clockIn", + handler: async (payload: unknown, _clientActionId: string) => + replayOfflineClock(payload as never), + }, + { + name: "timeTracking.clockOut", + handler: async (payload: unknown, _clientActionId: string) => + replayOfflineClock(payload as never), + }, + ]; } export async function dispatchClientAction( diff --git a/src/actions/time-tracking/entries.ts b/src/actions/time-tracking/entries.ts new file mode 100644 index 0000000..970a3a6 --- /dev/null +++ b/src/actions/time-tracking/entries.ts @@ -0,0 +1,378 @@ +/** + * Time Tracking — Manual time entry server actions. + * + * Cycle 12 (Phase 2): admins can add/edit/delete manual entries on a + * timesheet, but only when the timesheet is in `draft` / `rejected` / + * `unlocked` status. Locked (`approved`) timesheets must be unlocked + * first via `unlockTimesheet()`. + * + * Manual entries: + * - Carry `entry_kind = 'manual'` + a mandatory `manual_reason` + * - Bypass the partial-unique open-clock-in constraint + * - Recompute the parent timesheet's cached totals on every write + * + * All actions write to `time_tracking_audit_log` in the same transaction. + */ +"use server"; + +import { z } from "zod"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { withTx } from "@/lib/db"; +import { logTimeTrackingEventInTx } from "@/lib/time-tracking-audit"; + +// ── Schemas ──────────────────────────────────────────────────────────────── + +const GPS_SCHEMA = z + .object({ + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), + accuracy_m: z.number().min(0).max(10000).optional(), + }) + .optional(); + +const CreateSchema = z.object({ + brandId: z.string().uuid(), + workerId: z.string().uuid(), + taskName: z.string().min(1).max(120), + clockIn: z.string().datetime(), + clockOut: z.string().datetime().optional().nullable(), + lunchBreakMinutes: z.number().int().min(0).max(720).default(0), + notes: z.string().max(2000).optional(), + manualReason: z.string().min(3).max(500), + gps: GPS_SCHEMA, +}); + +export async function addManualTimeEntry( + input: z.infer, +): Promise<{ success: boolean; entry_id?: string; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const parsed = CreateSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + const data = parsed.data; + + return withTx(async (client) => { + // Verify worker belongs to the brand + const fw = await client.query<{ brand_id: string }>( + `SELECT brand_id FROM field_workers WHERE id = $1 LIMIT 1`, + [data.workerId], + ); + if (fw.rows.length === 0 || fw.rows[0].brand_id !== data.brandId) { + return { success: false, error: "Worker not found in this brand" }; + } + + // Compute the pay-period [start, end] that contains clockIn + const period = computePeriod(data.clockIn); + + // Lock the timesheet row (FOR UPDATE) so we can safely decide whether + // to insert or update the parent timesheet. + const tsRes = await client.query<{ + id: string; + locked_at: Date | null; + }>( + `SELECT id, locked_at FROM time_tracking_timesheets + WHERE field_worker_id = $1 + AND period_start = $2::date + AND period_end = $3::date + FOR UPDATE`, + [data.workerId, period.start, period.end], + ); + let timesheetId = tsRes.rows[0]?.id; + if (tsRes.rows[0]?.locked_at) { + return { + success: false, + error: "Timesheet is locked — ask an admin to unlock it first", + }; + } + + if (!timesheetId) { + const created = await client.query<{ id: string }>( + `INSERT INTO time_tracking_timesheets + (brand_id, field_worker_id, period_start, period_end) + VALUES ($1, $2, $3::date, $4::date) + RETURNING id`, + [data.brandId, data.workerId, period.start, period.end], + ); + timesheetId = created.rows[0]?.id; + if (!timesheetId) return { success: false, error: "Failed to create timesheet" }; + } + + const gpsVerified = !!data.gps && (data.gps.accuracy_m ?? 999) <= 100; + + const inserted = await client.query<{ id: string }>( + `INSERT INTO time_tracking_logs + (brand_id, field_worker_id, task_name, + clock_in, clock_out, lunch_break_minutes, notes, + submitted_via, entry_kind, manual_reason, + clock_in_lat, clock_in_lng, clock_in_accuracy_m, + gps_verified, + edited_at, edited_by, edit_reason) + VALUES ($1, $2, $3, + $4, $5, $6, $7, + 'manual', 'manual', $8, + $9, $10, $11, + $12, + NOW(), $13, $14) + RETURNING id`, + [ + data.brandId, + data.workerId, + data.taskName, + new Date(data.clockIn), + data.clockOut ? new Date(data.clockOut) : null, + data.lunchBreakMinutes, + data.notes ?? null, + data.manualReason, + data.gps?.lat ?? null, + data.gps?.lng ?? null, + data.gps?.accuracy_m ?? null, + gpsVerified, + adminUser.id, + data.manualReason, + ], + ); + const entryId = inserted.rows[0]?.id; + if (!entryId) return { success: false, error: "Insert failed" }; + + await logTimeTrackingEventInTx(client, { + brandId: data.brandId, + actorId: adminUser.id, + actorLabel: adminUser.display_name ?? adminUser.email ?? "admin", + action: "entry.create", + entityType: "log", + entityId: entryId, + details: { + kind: "manual", + worker_id: data.workerId, + clock_in: data.clockIn, + clock_out: data.clockOut, + reason: data.manualReason, + }, + }); + + await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]); + + return { success: true, entry_id: entryId }; + }); +} + +const UpdateSchema = z.object({ + entryId: z.string().uuid(), + taskName: z.string().min(1).max(120).optional(), + clockIn: z.string().datetime().optional(), + clockOut: z.string().datetime().nullable().optional(), + lunchBreakMinutes: z.number().int().min(0).max(720).optional(), + notes: z.string().max(2000).nullable().optional(), + manualReason: z.string().min(3).max(500), + gps: GPS_SCHEMA, +}); + +export async function editTimeEntry( + input: z.infer, +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const parsed = UpdateSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + const data = parsed.data; + + return withTx(async (client) => { + const cur = await client.query<{ + brand_id: string; + field_worker_id: string; + locked_at: Date | null; + }>( + `SELECT t.brand_id, t.field_worker_id, ts.locked_at + FROM time_tracking_logs t + LEFT JOIN time_tracking_timesheets ts + ON ts.field_worker_id = t.field_worker_id + AND ts.period_start <= t.clock_in::date + AND ts.period_end >= t.clock_in::date + WHERE t.id = $1 + FOR UPDATE OF t`, + [data.entryId], + ); + if (cur.rows.length === 0) return { success: false, error: "Not found" }; + if (cur.rows[0].locked_at) + return { + success: false, + error: "Timesheet is locked — ask an admin to unlock it first", + }; + + const gpsVerified = + data.gps != null ? (data.gps.accuracy_m ?? 999) <= 100 : undefined; + + // Build a dynamic UPDATE so we only touch columns that the caller + // actually changed. Cleanly handles the clockOut=null sentinel for + // "clear the clock-out". + const sets: string[] = [ + "edited_at = NOW()", + "edited_by = $1", + "edit_reason = $2", + ]; + const params: unknown[] = [adminUser.id, data.manualReason]; + let i = params.length; + + if (data.taskName !== undefined) { + i += 1; + sets.push(`task_name = $${i}`); + params.push(data.taskName); + } + if (data.clockIn !== undefined) { + i += 1; + sets.push(`clock_in = $${i}`); + params.push(new Date(data.clockIn)); + } + if (data.clockOut !== undefined) { + i += 1; + sets.push(`clock_out = $${i}::timestamptz`); + params.push(data.clockOut ? new Date(data.clockOut) : null); + } + if (data.lunchBreakMinutes !== undefined) { + i += 1; + sets.push(`lunch_break_minutes = $${i}`); + params.push(data.lunchBreakMinutes); + } + if (data.notes !== undefined) { + i += 1; + sets.push(`notes = $${i}`); + params.push(data.notes); + } + if (data.gps !== undefined) { + i += 1; + sets.push(`clock_in_lat = $${i}`); + params.push(data.gps.lat); + i += 1; + sets.push(`clock_in_lng = $${i}`); + params.push(data.gps.lng); + i += 1; + sets.push(`clock_in_accuracy_m = $${i}`); + params.push(data.gps.accuracy_m ?? null); + if (gpsVerified !== undefined) { + i += 1; + sets.push(`gps_verified = $${i}`); + params.push(gpsVerified); + } + } + + i += 1; + const entryIdParam = i; + params.push(data.entryId); + + await client.query( + `UPDATE time_tracking_logs SET ${sets.join(", ")} WHERE id = $${entryIdParam}`, + params, + ); + + await logTimeTrackingEventInTx(client, { + brandId: cur.rows[0].brand_id, + actorId: adminUser.id, + actorLabel: adminUser.display_name ?? adminUser.email ?? "admin", + action: "entry.edit", + entityType: "log", + entityId: data.entryId, + details: { + worker_id: cur.rows[0].field_worker_id, + reason: data.manualReason, + }, + }); + + // Recompute the owning timesheet + await client.query( + `SELECT recompute_timesheet_totals(ts.id) + FROM time_tracking_timesheets ts + WHERE ts.field_worker_id = $1 + LIMIT 1`, + [cur.rows[0].field_worker_id], + ); + + return { success: true }; + }); +} + +export async function deleteTimeEntry( + entryId: string, + reason: string, +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!reason || reason.length < 3) + return { success: false, error: "Reason required" }; + + return withTx(async (client) => { + const cur = await client.query<{ + brand_id: string; + field_worker_id: string; + locked_at: Date | null; + }>( + `SELECT t.brand_id, t.field_worker_id, ts.locked_at + FROM time_tracking_logs t + LEFT JOIN time_tracking_timesheets ts + ON ts.field_worker_id = t.field_worker_id + AND ts.period_start <= t.clock_in::date + AND ts.period_end >= t.clock_in::date + WHERE t.id = $1 + FOR UPDATE OF t`, + [entryId], + ); + if (cur.rows.length === 0) return { success: false, error: "Not found" }; + if (cur.rows[0].locked_at) + return { + success: false, + error: "Timesheet is locked — ask an admin to unlock it first", + }; + + await client.query(`DELETE FROM time_tracking_logs WHERE id = $1`, [entryId]); + + await logTimeTrackingEventInTx(client, { + brandId: cur.rows[0].brand_id, + actorId: adminUser.id, + actorLabel: adminUser.display_name ?? adminUser.email ?? "admin", + action: "entry.delete", + entityType: "log", + entityId: entryId, + details: { worker_id: cur.rows[0].field_worker_id, reason }, + }); + + await client.query( + `SELECT recompute_timesheet_totals(ts.id) + FROM time_tracking_timesheets ts + WHERE ts.field_worker_id = $1 + LIMIT 1`, + [cur.rows[0].field_worker_id], + ); + + return { success: true }; + }); +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +function computePeriod(isoDate: string): { start: string; end: string } { + // Default weekly period (Sunday → Saturday). The brand admin can + // override this via time_tracking_settings.pay_period_length_days + + // pay_period_start_day; the SQL recompute_timesheet_totals RPC + // understands any non-zero length. + const d = new Date(isoDate); + const start = new Date(d); + start.setHours(0, 0, 0, 0); + start.setDate(d.getDate() - d.getDay()); + const end = new Date(start); + end.setDate(start.getDate() + 6); + return { + start: start.toISOString().slice(0, 10), + end: end.toISOString().slice(0, 10), + }; +} \ No newline at end of file diff --git a/src/actions/time-tracking/export.ts b/src/actions/time-tracking/export.ts new file mode 100644 index 0000000..a1cdcf7 --- /dev/null +++ b/src/actions/time-tracking/export.ts @@ -0,0 +1,326 @@ +/** + * Time Tracking — Payroll export (CSV + PDF). + * + * Two endpoints, both keyed on a `timesheetId`: + * - CSV: stable, machine-friendly. Columns match the format the Tuxedo + * payroll service already ingests for the water log so we can re-use + * the same import tool on the payroll side. + * - PDF: signature-ready. Includes worker, period, totals, every + * entry with start/end, manual entries highlighted, supervisor + * approval signature line, and an audit footer. + * + * Both lock behind the same authz: caller must be admin or the timesheet's + * worker (or assigned supervisor). Locked timesheets export; the + * approval status is what makes them payroll-ready. + */ +"use server"; + +import { eq } from "drizzle-orm"; +import { withBrand } from "@/db/client"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { + timeTrackingLogs, + timeTrackingTimesheets, +} from "@/db/schema/time-tracking"; +import { fieldWorkers } from "@/db/schema/field-workers"; +import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; +import Papa from "papaparse"; + +// ── CSV ───────────────────────────────────────────────────────────────────── + +export type ExportRow = { + date: string; + worker_name: string; + worker_role: string; + task: string; + entry_kind: "clock" | "manual"; + clock_in: string; + clock_out: string; + break_minutes: number; + total_minutes: number; + gps_verified: boolean; + manual_reason: string | null; + notes: string | null; +}; + +export type ExportPayload = { + brand_name: string; + worker_name: string; + worker_role: string; + period_start: string; + period_end: string; + status: string; + reviewer: string | null; + approved_at: string | null; + rows: ExportRow[]; + totals: { + entry_count: number; + total_minutes: number; + regular_minutes: number; + overtime_minutes: number; + break_minutes: number; + }; +}; + +export async function exportTimesheetCsv( + timesheetId: string, +): Promise<{ success: boolean; csv?: string; filename?: string; error?: string }> { + const payload = await loadExportPayload(timesheetId); + if (!payload) return { success: false, error: "Not authorized or not found" }; + + const csv = Papa.unparse( + payload.rows.map((r) => ({ + Date: r.date, + Worker: r.worker_name, + Role: r.worker_role, + Task: r.task, + Kind: r.entry_kind, + "Clock In": r.clock_in, + "Clock Out": r.clock_out, + "Break (min)": r.break_minutes, + "Total (min)": r.total_minutes, + "GPS Verified": r.gps_verified ? "yes" : "no", + "Manual Reason": r.manual_reason ?? "", + Notes: r.notes ?? "", + })), + ); + + const header = + `# ${payload.brand_name}\n` + + `# Worker: ${payload.worker_name} (${payload.worker_role})\n` + + `# Period: ${payload.period_start} → ${payload.period_end}\n` + + `# Status: ${payload.status}\n` + + `# Approver: ${payload.reviewer ?? "(none)"}\n` + + `# Approved at: ${payload.approved_at ?? "(not approved)"}\n` + + `# Totals: ${payload.totals.total_minutes} min total · ` + + `${payload.totals.regular_minutes} reg · ` + + `${payload.totals.overtime_minutes} OT · ` + + `${payload.totals.break_minutes} break\n` + + `#\n`; + + return { + success: true, + csv: header + csv + "\n", + filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.csv`, + }; +} + +// ── PDF ───────────────────────────────────────────────────────────────────── + +export async function exportTimesheetPdf( + timesheetId: string, +): Promise<{ success: boolean; pdf?: Uint8Array; filename?: string; error?: string }> { + const payload = await loadExportPayload(timesheetId); + if (!payload) return { success: false, error: "Not authorized or not found" }; + + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold); + + const pageSize: [number, number] = [612, 792]; // US Letter + const margin = 40; + const lineHeight = 14; + + let page = pdf.addPage(pageSize); + let cursor = pageSize[1] - margin; + + function ensureRoom(lines = 1) { + if (cursor - lines * lineHeight < margin) { + page = pdf.addPage(pageSize); + cursor = pageSize[1] - margin; + } + } + + function drawText(text: string, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) { + const size = opts.size ?? 10; + const f = opts.bold ? fontBold : font; + page.drawText(text, { + x: margin, + y: cursor - size, + size, + font: f, + color: opts.color ? rgb(...opts.color) : rgb(0, 0, 0), + }); + cursor -= size + 4; + } + + // Header + drawText(payload.brand_name, { bold: true, size: 16 }); + drawText(`Timesheet — ${payload.worker_name} (${payload.worker_role})`, { + size: 12, + }); + drawText( + `Period: ${payload.period_start} → ${payload.period_end} · Status: ${payload.status}`, + ); + if (payload.reviewer) { + drawText(`Approver: ${payload.reviewer} · Approved: ${payload.approved_at}`); + } + cursor -= 4; + drawText("Totals", { bold: true }); + drawText( + `Entries: ${payload.totals.entry_count} · ` + + `Total: ${formatHM(payload.totals.total_minutes)} · ` + + `Regular: ${formatHM(payload.totals.regular_minutes)} · ` + + `OT: ${formatHM(payload.totals.overtime_minutes)} · ` + + `Break: ${formatHM(payload.totals.break_minutes)}`, + ); + cursor -= 8; + + // Entries + drawText("Entries", { bold: true }); + for (const r of payload.rows) { + ensureRoom(3); + const tag = r.entry_kind === "manual" ? "[manual] " : ""; + const gps = r.gps_verified ? "📍" : "—"; + drawText( + `${r.date} ${tag}${r.task} ${gps} ${r.clock_in} → ${r.clock_out} ` + + `(${formatHM(r.total_minutes)})`, + { size: 9 }, + ); + if (r.notes) drawText(` notes: ${r.notes}`, { size: 8, color: [0.4, 0.4, 0.4] }); + if (r.manual_reason) + drawText(` reason: ${r.manual_reason}`, { + size: 8, + color: [0.4, 0.4, 0.4], + }); + } + + cursor -= 24; + ensureRoom(2); + drawText("Signatures", { bold: true }); + drawText("Employee: ____________________________________ Date: ______________"); + cursor -= 18; + drawText("Supervisor: __________________________________ Date: ______________"); + + cursor -= 28; + ensureRoom(2); + drawText( + `Generated ${new Date().toISOString()} · audit id: ${timesheetId.slice(0, 8)}`, + { size: 7, color: [0.5, 0.5, 0.5] }, + ); + + const pdfBytes = await pdf.save(); + return { + success: true, + pdf: pdfBytes, + filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.pdf`, + }; +} + +// ── Loader ────────────────────────────────────────────────────────────────── + +async function loadExportPayload( + timesheetId: string, +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + return withBrand(adminUser.brand_id ?? "", async (db) => { + const [ts] = await db + .select({ + id: timeTrackingTimesheets.id, + brand_id: timeTrackingTimesheets.brandId, + field_worker_id: timeTrackingTimesheets.fieldWorkerId, + period_start: timeTrackingTimesheets.periodStart, + period_end: timeTrackingTimesheets.periodEnd, + status: timeTrackingTimesheets.status, + total_minutes: timeTrackingTimesheets.totalMinutes, + regular_minutes: timeTrackingTimesheets.regularMinutes, + overtime_minutes: timeTrackingTimesheets.overtimeMinutes, + break_minutes: timeTrackingTimesheets.breakMinutes, + entry_count: timeTrackingTimesheets.entryCount, + approved_at: timeTrackingTimesheets.reviewedAt, + reviewer_name: fieldWorkers.name, + }) + .from(timeTrackingTimesheets) + .leftJoin( + fieldWorkers, + eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId), + ) + .where(eq(timeTrackingTimesheets.id, timesheetId)) + .limit(1); + + if (!ts) return null; + if ( + adminUser.role !== "platform_admin" && + adminUser.brand_id !== ts.brand_id + ) + return null; + + const [worker] = await db + .select() + .from(fieldWorkers) + .where(eq(fieldWorkers.id, ts.field_worker_id)) + .limit(1); + if (!worker) return null; + + const entries = await db + .select() + .from(timeTrackingLogs) + .where(eq(timeTrackingLogs.fieldWorkerId, ts.field_worker_id)) + .orderBy(timeTrackingLogs.clockIn); + + const rows: ExportRow[] = entries + .filter( + (e) => + e.clockIn >= new Date(ts.period_start) && + e.clockIn <= endOfDay(ts.period_end), + ) + .map((e) => { + const totalMin = + e.clockOut + ? Math.max( + 0, + Math.round( + (e.clockOut.getTime() - e.clockIn.getTime()) / 60000, + ) - e.lunchBreakMinutes, + ) + : 0; + return { + date: e.clockIn.toISOString().slice(0, 10), + worker_name: worker.name, + worker_role: worker.role, + task: e.taskName, + entry_kind: e.entryKind as "clock" | "manual", + clock_in: e.clockIn.toISOString().slice(11, 19), + clock_out: e.clockOut ? e.clockOut.toISOString().slice(11, 19) : "", + break_minutes: e.lunchBreakMinutes, + total_minutes: totalMin, + gps_verified: e.gpsVerified, + manual_reason: e.manualReason, + notes: e.notes, + }; + }); + + return { + brand_name: adminUser.brand_slug ?? "Brand", + worker_name: worker.name, + worker_role: worker.role, + period_start: ts.period_start, + period_end: ts.period_end, + status: ts.status, + reviewer: ts.reviewer_name, + approved_at: ts.approved_at ? ts.approved_at.toISOString() : null, + rows, + totals: { + entry_count: ts.entry_count, + total_minutes: ts.total_minutes, + regular_minutes: ts.regular_minutes, + overtime_minutes: ts.overtime_minutes, + break_minutes: ts.break_minutes, + }, + }; + }); +} + +function endOfDay(iso: string): Date { + const d = new Date(iso); + d.setHours(23, 59, 59, 999); + return d; +} + +function formatHM(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${h}h ${m}m`; +} \ No newline at end of file diff --git a/src/actions/time-tracking/field.ts b/src/actions/time-tracking/field.ts index e7f75b0..19c1cef 100644 --- a/src/actions/time-tracking/field.ts +++ b/src/actions/time-tracking/field.ts @@ -21,7 +21,7 @@ "use server"; import { cookies } from "next/headers"; -import { eq, and, isNull, desc, sql } from "drizzle-orm"; +import { eq, and, isNull, desc, sql, gte, lte } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { @@ -152,14 +152,23 @@ export async function verifyTimeTrackingPin( // ── Clock In ─────────────────────────────────────────────────────────────── +export type GpsSample = { + lat: number; + lng: number; + /** Reported accuracy in meters. ≤ 100m = `gps_verified = true`. */ + accuracy_m?: number; +}; + export async function clockInWorker( brandId: string, taskId?: string, taskName = "General Labor", + gps?: GpsSample | null, ): Promise<{ success: boolean; log_id?: string; clock_in?: string; + gps_verified?: boolean; error?: string; }> { const session = await getTimeTrackingSession(); @@ -199,6 +208,8 @@ export async function clockInWorker( } const now = new Date(); + const gpsVerified = + gps != null && (gps.accuracy_m ?? 999) <= 100; try { const [row] = await db .insert(timeTrackingLogs) @@ -209,6 +220,12 @@ export async function clockInWorker( taskName: resolvedTaskName, clockIn: now, submittedVia: "field", + // 0098 — GPS + entry_kind + entryKind: "clock", + clockInLat: gps?.lat ?? null, + clockInLng: gps?.lng ?? null, + clockInAccuracyM: gps?.accuracy_m ?? null, + gpsVerified, }) .returning({ id: timeTrackingLogs.id, @@ -220,6 +237,7 @@ export async function clockInWorker( success: true, log_id: row.id, clock_in: row.clockIn.toISOString(), + gps_verified: gpsVerified, }; } catch (err) { // Race lost to a concurrent clock-in: the partial unique index @@ -267,11 +285,13 @@ export async function enqueueClockOutForSync( export async function clockOutWorker( lunchMinutes = 0, notes?: string, + gps?: GpsSample | null, ): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; + gps_verified?: boolean; error?: string; }> { const session = await getTimeTrackingSession(); @@ -296,12 +316,22 @@ export async function clockOutWorker( const log = open[0]; const now = new Date(); + const gpsVerified = + gps != null && (gps.accuracy_m ?? 999) <= 100; await db .update(timeTrackingLogs) .set({ clockOut: now, lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)), notes: notes?.trim() || null, + // 0098 — GPS on clock-out + clockOutLat: gps?.lat ?? null, + clockOutLng: gps?.lng ?? null, + clockOutAccuracyM: gps?.accuracy_m ?? null, + // Preserve the existing `gps_verified` if clock-in already verified; + // upgrade only when clock-out GPS is fresh AND accurate. + gpsVerified: + log.gpsVerified || gpsVerified ? true : log.gpsVerified, }) .where(eq(timeTrackingLogs.id, log.id)); @@ -321,15 +351,40 @@ export async function clockOutWorker( Math.round((now.getTime() - log.clockIn.getTime()) / 60000) - Math.max(0, Math.round(lunchMinutes)), ); + + // Recompute the owning timesheet totals (0098). + void recomputeOwningTimesheet(session.brand_id, log.id).catch(() => {}); + return { success: true, log_id: log.id, clock_out: now.toISOString(), total_minutes: totalMinutes, + gps_verified: gpsVerified, }; }); } +/** + * Fire-and-forget: recompute the parent timesheet totals for a log. + * Errors are swallowed — the cached totals can be off by one entry until + * the next mutation; this is preferable to surfacing a 500 on clock-out. + */ +async function recomputeOwningTimesheet( + brandId: string, + logId: string, +): Promise { + const pool = (await import("@/lib/db")).getPool(); + await pool.query( + `SELECT recompute_timesheet_totals(ts.id) + FROM time_tracking_timesheets ts + JOIN time_tracking_logs l ON l.field_worker_id = ts.field_worker_id + WHERE l.id = $1 AND ts.brand_id = $2 + LIMIT 1`, + [logId, brandId], + ); +} + // ── Get Open Clock In ────────────────────────────────────────────────────── export async function getOpenClockIn(brandId: string): Promise<{ @@ -509,3 +564,260 @@ export async function getWorkerPayPeriodHours( weekly_threshold: totals.weekly_threshold, }; } + +// ── Manual time entry (Hub → Manual Entry tile) ─────────────────────────── +// +// Cycle 12: PIN now lands on a Hub screen, and one of the tiles opens a +// "manual entry" form for missed clock-in/clock-out shifts. The form +// inserts directly into `time_tracking_logs` with `entry_kind='manual'` +// and a required `manual_reason`. The same row shape is reused by the +// admin timesheet UI (see `src/actions/time-tracking/entries.ts`). +// +// We deliberately keep these safety bounds: +// • Clock-in must be within the last 30 days (workers cannot backfill +// last month; payroll is already closed). +// • Clock-out must be > clock-in. +// • Duration cannot exceed 16 hours (a single shift ceiling matching the +// admin UI guard). +// • Reason must be ≥ 3 chars (so "x" can't sneak through). +// • GPS is not surfaced — manual entries never have GPS, by definition. +// +// The row is inserted via `withBrand` so RLS context is preserved, and +// Smartsheet sync is enqueued best-effort so it matches live clock-outs. + +const MAX_MANUAL_LOOKBACK_DAYS = 30; +const MAX_MANUAL_HOURS = 16; +const MIN_MANUAL_REASON_LENGTH = 3; + +export type SubmitManualTimeLogInput = { + brandId?: string; + clockInIso: string; + clockOutIso: string; + lunchBreakMinutes?: number; + taskId?: string | null; + taskName?: string | null; + notes?: string | null; + reason: string; +}; + +export type SubmitManualTimeLogResult = + | { + success: true; + log_id: string; + clock_in: string; + clock_out: string; + total_minutes: number; + } + | { success: false; error: string }; + +export async function submitManualTimeLog( + input: SubmitManualTimeLogInput, +): Promise { + const session = await getTimeTrackingSession(); + if (!session) return { success: false, error: "Not logged in" }; + + // Brand resolution: caller may pass an explicit brandId (e.g. the admin + // manage page passing platform_admin context), but the field worker app + // has only one brand — the session's brand_id. + const brandId = input.brandId ?? session.brand_id; + if (!brandId) { + return { success: false, error: "Missing brand context" }; + } + + const reason = input.reason.trim(); + if (reason.length < MIN_MANUAL_REASON_LENGTH) { + return { + success: false, + error: "Please add a brief reason (at least 3 characters)", + }; + } + + // Parse + sanity-check the timestamps before hitting the DB. + const clockIn = new Date(input.clockInIso); + const clockOut = new Date(input.clockOutIso); + if (Number.isNaN(clockIn.getTime())) { + return { success: false, error: "Clock-in time is invalid" }; + } + if (Number.isNaN(clockOut.getTime())) { + return { success: false, error: "Clock-out time is invalid" }; + } + if (clockIn.getTime() >= clockOut.getTime()) { + return { success: false, error: "Clock-out must be after clock-in" }; + } + const durationMs = clockOut.getTime() - clockIn.getTime(); + const durationMinutes = Math.round(durationMs / 60000); + if (durationMinutes > MAX_MANUAL_HOURS * 60) { + return { + success: false, + error: `Shift cannot exceed ${MAX_MANUAL_HOURS} hours`, + }; + } + + const now = new Date(); + const lookbackCutoff = new Date( + now.getTime() - MAX_MANUAL_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + if (clockIn.getTime() < lookbackCutoff.getTime()) { + return { + success: false, + error: `Clock-in is more than ${MAX_MANUAL_LOOKBACK_DAYS} days ago`, + }; + } + if (clockIn.getTime() > now.getTime()) { + return { success: false, error: "Clock-in cannot be in the future" }; + } + + const lunchMinutes = Math.max( + 0, + Math.min( + Math.round(input.lunchBreakMinutes ?? 0), + Math.max(0, durationMinutes - 1), + ), + ); + + // Resolve a friendly task name when the worker picked a task. Tasks are + // brand-scoped, so we resolve inside the brand transaction to avoid + // querying wrong-brand rows. + let resolvedTaskId: string | null = input.taskId ?? null; + let resolvedTaskName = (input.taskName ?? "").trim() || "Manual entry"; + + const result = await withBrand(brandId, async (db) => { + if (resolvedTaskId) { + const t = await db + .select({ name: timeTrackingTasks.name }) + .from(timeTrackingTasks) + .where( + and( + eq(timeTrackingTasks.id, resolvedTaskId), + eq(timeTrackingTasks.brandId, brandId), + ), + ) + .limit(1); + if (t.length > 0) resolvedTaskName = t[0].name; + } + + const inserted = await db + .insert(timeTrackingLogs) + .values({ + brandId, + fieldWorkerId: session.worker_id, + taskId: resolvedTaskId, + taskName: resolvedTaskName, + clockIn, + clockOut, + lunchBreakMinutes: lunchMinutes, + notes: input.notes?.trim() || null, + // Manual entries: enum='manual', reason is required, no GPS. + entryKind: "manual", + manualReason: reason, + submittedVia: "manual", + gpsVerified: false, + }) + .returning({ id: timeTrackingLogs.id }); + return inserted[0]?.id; + }); + + if (!result) { + return { success: false, error: "Failed to save entry" }; + } + + // Best-effort Smartsheet sync enqueue + timesheet recompute. Same + // fire-and-forget posture as `clockOutWorker`. + void enqueueClockOutForSync(brandId, result).catch(() => {}); + void recomputeOwningTimesheet(brandId, result).catch(() => {}); + + const totalMinutes = Math.max(0, durationMinutes - lunchMinutes); + + return { + success: true, + log_id: result, + clock_in: clockIn.toISOString(), + clock_out: clockOut.toISOString(), + total_minutes: totalMinutes, + }; +} + +// ── Recent log fetcher (Hub + Recent Activity screen) ────────────────────── + +/** A flattened, presentation-ready log row. Used by both the Hub hero card + * ("logged today" preview) and the Recent Activity screen. */ +export type WorkerLogRow = { + log_id: string; + clock_in: string; + clock_out: string | null; + task_name: string; + entry_kind: "clock" | "manual"; + lunch_break_minutes: number; + total_minutes: number; // 0 if open shift (clock_out is null) + notes: string | null; + manual_reason: string | null; +}; + +export type RecentTimeLogsResult = + | { success: true; logs: WorkerLogRow[] } + | { success: false; error: string }; + +export async function getRecentTimeLogs( + brandId: string, + days = 14, +): Promise { + const session = await getTimeTrackingSession(); + if (!session) return { success: false, error: "Not logged in" }; + + const safeDays = Math.max(1, Math.min(days, 90)); + const since = new Date( + Date.now() - safeDays * 24 * 60 * 60 * 1000, + ).toISOString(); + + return withBrand(brandId, async (db) => { + const rows = await db + .select({ + id: timeTrackingLogs.id, + clockIn: timeTrackingLogs.clockIn, + clockOut: timeTrackingLogs.clockOut, + taskName: timeTrackingLogs.taskName, + entryKind: timeTrackingLogs.entryKind, + lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes, + notes: timeTrackingLogs.notes, + manualReason: timeTrackingLogs.manualReason, + }) + .from(timeTrackingLogs) + .where( + and( + eq(timeTrackingLogs.brandId, brandId), + eq(timeTrackingLogs.fieldWorkerId, session.worker_id), + gte(timeTrackingLogs.clockIn, new Date(since)), + // Only show logs that have a clock-out (the Recent Activity + // card represents *completed* shifts). Open shifts are shown + // on the Hub hero card via `getOpenClockIn`. + ), + ) + .orderBy(desc(timeTrackingLogs.clockIn)) + .limit(200); + + const logs: WorkerLogRow[] = rows.map((r) => { + const total_minutes = + r.clockOut != null + ? Math.max( + 0, + Math.round( + (r.clockOut.getTime() - r.clockIn.getTime()) / 60000, + ) - r.lunchBreakMinutes, + ) + : 0; + return { + log_id: r.id, + clock_in: r.clockIn.toISOString(), + clock_out: r.clockOut ? r.clockOut.toISOString() : null, + task_name: r.taskName, + entry_kind: r.entryKind, + lunch_break_minutes: r.lunchBreakMinutes, + total_minutes, + notes: r.notes, + manual_reason: r.manualReason, + }; + }); + + return { success: true, logs }; + }); +} diff --git a/src/actions/time-tracking/offline-handlers.ts b/src/actions/time-tracking/offline-handlers.ts new file mode 100644 index 0000000..6bbb76b --- /dev/null +++ b/src/actions/time-tracking/offline-handlers.ts @@ -0,0 +1,104 @@ +/** + * Time Tracking — Offline replay handlers. + * + * Wired into the offline dispatcher's `listClientActions()` so the field + * app's IndexedDB queue can replay clock-in / clock-out events when the + * device comes back online. + * + * Re-auth model: + * The field app stores the worker's PIN locally (encrypted at rest by + * the browser) for the lifetime of the session cookie. On PIN entry, + * the PIN is written to IndexedDB so a subsequent offline clock event + * can carry it. The replay server action re-hashes + compares against + * `field_workers.pin_hash` to authenticate the replay. + * + * Audit: + * Replays land with `submitted_via = 'offline_sync'`, a distinct + * value from `'field'` and `'manual'`, so payroll can spot and verify + * any entry that didn't have a live GPS fix. + */ +"use server"; + +import { verifyPin } from "@/lib/water-log-pin"; +import { withPlatformAdmin } from "@/db/client"; +import { fieldWorkers } from "@/db/schema/field-workers"; +import { clockInWorker, clockOutWorker, type GpsSample } from "./field"; + +export type OfflineClockPayload = + | { + kind: "clock_in"; + brandId: string; + workerId: string; + pin: string; + taskName: string; + gps?: { lat: number; lng: number; accuracy_m?: number } | null; + clientTs: string; + } + | { + kind: "clock_out"; + brandId: string; + workerId: string; + pin: string; + lunchMinutes: number; + notes?: string; + gps?: { lat: number; lng: number; accuracy_m?: number } | null; + clientTs: string; + }; + +/** + * Re-verify a PIN against the `field_workers` table. Looks up across all + * brands (mirrors the field verify path) and returns the matching worker + * or null. Includes the weak 200ms response-time penalty to discourage + * brute-force. + */ +async function reAuthWorker( + workerId: string, + pin: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + if (!pin || pin.length < 4) return { ok: false, error: "PIN required" }; + return withPlatformAdmin(async (db) => { + const rows = await db.select().from(fieldWorkers).limit(500); + const target = rows.find((r) => r.id === workerId); + if (!target || !target.active) { + await new Promise((r) => setTimeout(r, 200)); + return { ok: false as const, error: "Worker not found or inactive" }; + } + if (!verifyPin(pin, target.pinHash)) { + await new Promise((r) => setTimeout(r, 200)); + return { ok: false as const, error: "Invalid PIN" }; + } + return { ok: true as const }; + }); +} + +export async function replayOfflineClock( + payload: OfflineClockPayload, +): Promise<{ success: boolean; log_id?: string; error?: string }> { + const auth = await reAuthWorker(payload.workerId, payload.pin); + if (!auth.ok) return { success: false, error: auth.error }; + + if (payload.kind === "clock_in") { + const r = await clockInWorker( + payload.brandId, + undefined, + payload.taskName, + payload.gps as GpsSample | null, + ); + return { + success: r.success, + log_id: r.log_id, + error: r.error, + }; + } + + const r = await clockOutWorker( + payload.lunchMinutes, + payload.notes, + payload.gps as GpsSample | null, + ); + return { + success: r.success, + log_id: r.log_id, + error: r.error, + }; +} \ No newline at end of file diff --git a/src/actions/time-tracking/route-summary.ts b/src/actions/time-tracking/route-summary.ts new file mode 100644 index 0000000..a651cfb --- /dev/null +++ b/src/actions/time-tracking/route-summary.ts @@ -0,0 +1,268 @@ +/** + * Cross-link helper: "Route summary" = a worker-day bundle that shows + * BOTH water-log activity AND time-tracking totals side by side. + * + * The Water Log module works with field workers logging entries at + * headgates; the Time Tracking module has them clocking in/out. A + * "route" in the water-log domain is really a worker's day, and this + * action gives the UI a single object that summarises both halves of + * the day. + * + * Used by: + * - /admin/water-log users detail page (driver view) + * - /admin/time-tracking overview page (admin dashboard row) + * + * No mutation, no auth checks beyond getAdminUser(). Returns a typed + * shape so client components don't have to know about either module's + * table layout. + */ +"use server"; + +import { and, eq, gte, lt, sql } from "drizzle-orm"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { getPool } from "@/lib/db"; +import { withBrand } from "@/db/client"; +import { + timeTrackingLogs, + type TimesheetStatus, +} from "@/db/schema/time-tracking"; +import { fieldWorkers } from "@/db/schema/field-workers"; +import { waterLogEntries } from "@/db/schema/water-log"; +import { getTimeTrackingSettings } from "@/actions/time-tracking"; + +export type RouteTimeSummary = { + date: string; // YYYY-MM-DD + workerId: string; + workerName: string; + + water: { + entryCount: number; + totalGallons: number; + headgatesVisited: string[]; + }; + + time: { + clockedIn: boolean; + currentLogId: string | null; + clockInAt: string | null; + lastClockOutAt: string | null; + workedMinutesToday: number; + breakMinutesToday: number; + overtimeMinutesToday: number; + regularMinutesToday: number; + timesheetStatus: TimesheetStatus | null; + timesheetId: string | null; + }; +}; + +export type WorkerRouteRow = { + workerId: string; + workerName: string; + active: boolean; + water: { entryCount: number; totalGallons: number }; + time: { workedMinutesToday: number; isClockedIn: boolean }; +}; + +/** + * Compute the "today" snapshot for a single worker. + * `date` defaults to the local date in the admin's TZ (we use UTC + * midnight as a coarse cutoff; callers can pass a date they want). + */ +export async function getRouteTimeSummary( + brandId: string, + workerId: string, + date?: string, +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + const day = date ?? new Date().toISOString().slice(0, 10); + const start = new Date(`${day}T00:00:00.000Z`); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + 1); + + const worker = await withBrand(brandId, async (tx) => { + const rows = await tx + .select() + .from(fieldWorkers) + .where( + and( + eq(fieldWorkers.brandId, brandId), + eq(fieldWorkers.id, workerId), + ), + ) + .limit(1); + return rows[0] ?? null; + }); + if (!worker) return null; + + const pool = getPool(); + + // Water totals for the day + const waterRes = await pool.query<{ + entry_count: string; + total_gallons: string | null; + headgates: string[] | null; + }>( + `SELECT + COUNT(*)::text AS entry_count, + COALESCE(SUM(total_gallons), 0)::text AS total_gallons, + ARRAY_AGG(DISTINCT headgate_id::text) FILTER (WHERE headgate_id IS NOT NULL) AS headgates + FROM water_log_entries + WHERE brand_id = $1 + AND field_worker_id = $2 + AND logged_at >= $3 + AND logged_at < $4`, + [brandId, workerId, start.toISOString(), end.toISOString()], + ); + const wRow = waterRes.rows[0]; + + // Time totals for the day — read from time_tracking_logs directly, + // aggregating minutes from clock_in → (clock_out OR now). Counts an + // open log as in-progress; does NOT mutate. + const timeRes = await pool.query<{ + open_log_id: string | null; + open_clock_in: string | null; + last_clock_out: string | null; + worked_minutes: string; + break_minutes: string; + }>( + `SELECT + MAX(id) FILTER (WHERE clock_out IS NULL) AS open_log_id, + MAX(clock_in) FILTER (WHERE clock_out IS NULL) AS open_clock_in, + MAX(clock_out) AS last_clock_out, + COALESCE(SUM( + EXTRACT(EPOCH FROM ( + COALESCE(clock_out, NOW()) - clock_in + )) / 60 + ), 0)::text - COALESCE(SUM(lunch_minutes), 0)::text AS worked_minutes, + COALESCE(SUM(lunch_minutes), 0)::text AS break_minutes + FROM time_tracking_logs + WHERE brand_id = $1 + AND field_worker_id = $2 + AND clock_in >= $3 + AND clock_in < $4`, + [brandId, workerId, start.toISOString(), end.toISOString()], + ); + const tRow = timeRes.rows[0]; + + const settings = await getTimeTrackingSettings(brandId); + const dailyOtMin = settings?.daily_overtime_threshold ?? 480; + const workedMin = Math.max(0, Number(tRow?.worked_minutes ?? 0)); + const breakMin = Math.max(0, Number(tRow?.break_minutes ?? 0)); + const regularMin = Math.min(workedMin, dailyOtMin); + const otMin = Math.max(0, workedMin - dailyOtMin); + + // Find the timesheet (if any) that contains this date + const tsRes = await pool.query<{ id: string; status: TimesheetStatus }>( + `SELECT id, status + FROM time_tracking_timesheets + WHERE brand_id = $1 + AND field_worker_id = $2 + AND period_start <= $3::date + AND period_end >= $3::date + LIMIT 1`, + [brandId, workerId, day], + ); + + return { + date: day, + workerId, + workerName: worker.name || worker.pinHash.slice(0, 6) || "Worker", + water: { + entryCount: Number(wRow?.entry_count ?? 0), + totalGallons: Number(wRow?.total_gallons ?? 0), + headgatesVisited: (wRow?.headgates ?? []).filter(Boolean) as string[], + }, + time: { + clockedIn: tRow?.open_log_id != null, + currentLogId: tRow?.open_log_id ?? null, + clockInAt: tRow?.open_clock_in ?? null, + lastClockOutAt: tRow?.last_clock_out ?? null, + workedMinutesToday: workedMin, + breakMinutesToday: breakMin, + overtimeMinutesToday: otMin, + regularMinutesToday: regularMin, + timesheetStatus: tsRes.rows[0]?.status ?? null, + timesheetId: tsRes.rows[0]?.id ?? null, + }, + }; +} + +/** + * Fleet view: every active worker in the brand for "today". Used by + * the admin dashboard row so the dispatcher can see at a glance who's + * on the route AND whether they're on the clock. + */ +export async function getFleetRouteSummary( + brandId: string, + date?: string, +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return []; + + const day = date ?? new Date().toISOString().slice(0, 10); + const start = new Date(`${day}T00:00:00.000Z`); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + 1); + + const pool = getPool(); + const { rows } = await pool.query<{ + id: string; + display_name: string | null; + pin: string; + active: boolean; + water_entries: string; + water_gallons: string | null; + worked_minutes: string; + is_clocked_in: boolean; + }>( + `SELECT + fw.id, + fw.display_name, + fw.pin, + fw.active, + COALESCE(w.cnt, 0)::text AS water_entries, + COALESCE(w.gal, 0)::text AS water_gallons, + COALESCE(t.minutes, 0)::text AS worked_minutes, + COALESCE(t.clocked_in, FALSE) AS is_clocked_in + FROM field_workers fw + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS cnt, SUM(total_gallons) AS gal + FROM water_log_entries + WHERE brand_id = $1 + AND field_worker_id = fw.id + AND logged_at >= $2 + AND logged_at < $3 + ) w ON TRUE + LEFT JOIN LATERAL ( + SELECT SUM( + EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60 + - COALESCE(lunch_minutes, 0) + ) AS minutes, + BOOL_OR(clock_out IS NULL) AS clocked_in + FROM time_tracking_logs + WHERE brand_id = $1 + AND field_worker_id = fw.id + AND clock_in >= $2 + AND clock_in < $3 + ) t ON TRUE + WHERE fw.brand_id = $1 + ORDER BY fw.name NULLS LAST, fw.id`, + [brandId, start.toISOString(), end.toISOString()], + ); + + return rows.map((r) => ({ + workerId: r.id, + workerName: r.display_name || r.pin.slice(0, 6) || "Worker", + active: r.active, + water: { + entryCount: Number(r.water_entries ?? 0), + totalGallons: Number(r.water_gallons ?? 0), + }, + time: { + workedMinutesToday: Math.max(0, Number(r.worked_minutes ?? 0)), + isClockedIn: r.is_clocked_in, + }, + })); +} \ No newline at end of file diff --git a/src/actions/time-tracking/timesheets.ts b/src/actions/time-tracking/timesheets.ts new file mode 100644 index 0000000..eace65b --- /dev/null +++ b/src/actions/time-tracking/timesheets.ts @@ -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; + submitted_at: string | null; + submitted_notes: string | null; + reviewed_at: string | null; + reviewed_by_label: string | null; + reviewer_comment: string | null; + rejected_at: string | null; + rejection_reason: string | null; + locked_at: string | null; + unlocked_at: string | null; + unlock_audit_note: string | null; + updated_at: string; +}; + +export type AuditEntry = { + id: string; + actor_id: string | null; + actor_label: string; + actor_kind: string; + action: string; + entity_type: string; + entity_id: string | null; + details: Record; + created_at: string; +}; + +// ── Auth helpers ─────────────────────────────────────────────────────────── + +type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker"; + +type CallerContext = { + adminUser: NonNullable>>; + kind: CallerKind; + brandId: string; + /** When kind === 'supervisor', the field_worker row id of the supervisor. */ + supervisorFieldWorkerId: string | null; +}; + +/** + * Resolve the caller's role + brand context. Admins (platform/brand) are + * detected by `admin_users.role`. Supervisors are detected by matching + * `field_workers.role = 'supervisor'` for the admin's email. + */ +async function resolveCaller(brandId?: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + const resolvedBrandId = brandId ?? adminUser.brand_id ?? null; + if (!resolvedBrandId) return null; + + if (adminUser.role === "platform_admin") { + return { + adminUser, + kind: "platform_admin", + brandId: resolvedBrandId, + supervisorFieldWorkerId: null, + }; + } + + if (adminUser.role === "brand_admin") { + return { + adminUser, + kind: "brand_admin", + brandId: resolvedBrandId, + supervisorFieldWorkerId: null, + }; + } + + // store_employee or other: lookup as supervisor via email match + return withPlatformAdmin(async (db) => { + const fw = await db + .select() + .from(fieldWorkers) + .where( + and( + eq(fieldWorkers.brandId, adminUser.brand_id ?? ""), + sql`LOWER(${fieldWorkers.name}) = LOWER(${adminUser.email ?? ""})`, + eq(fieldWorkers.role, "supervisor"), + ), + ) + .limit(1); + if (fw.length > 0) { + return { + adminUser, + kind: "supervisor" as const, + brandId: resolvedBrandId, + supervisorFieldWorkerId: fw[0].id, + }; + } + return { + adminUser, + kind: "worker" as const, + brandId: resolvedBrandId, + supervisorFieldWorkerId: null, + }; + }); +} + +// ── List timesheets ──────────────────────────────────────────────────────── + +export async function listTimesheets( + brandId: string, + options: { + status?: TimesheetStatus | "all"; + workerId?: string; + periodStart?: string; // YYYY-MM-DD + periodEnd?: string; + limit?: number; + offset?: number; + } = {}, +): Promise<{ rows: TimesheetSummary[]; total: number }> { + const caller = await resolveCaller(brandId); + if (!caller) return { rows: [], total: 0 }; + + return withBrand(caller.brandId, async (db) => { + const conds: SQL[] = [eq(timeTrackingTimesheets.brandId, caller.brandId)]; + if (options.status && options.status !== "all") { + conds.push( + eq(timeTrackingTimesheets.status, options.status as TimesheetStatus), + ); + } + if (options.workerId) { + conds.push(eq(timeTrackingTimesheets.fieldWorkerId, options.workerId)); + } + if (options.periodStart) { + conds.push(gte(timeTrackingTimesheets.periodStart, options.periodStart)); + } + if (options.periodEnd) { + conds.push(lte(timeTrackingTimesheets.periodEnd, options.periodEnd)); + } + + // Supervisor scoping: only their assigned supervisees + if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { + const assigned = await db + .select({ + id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId, + }) + .from(timeTrackingSupervisorAssignments) + .where( + eq( + timeTrackingSupervisorAssignments.supervisorFieldWorkerId, + caller.supervisorFieldWorkerId, + ), + ); + const ids = assigned.map((a) => a.id); + if (ids.length === 0) return { rows: [], total: 0 }; + conds.push(inArray(timeTrackingTimesheets.fieldWorkerId, ids)); + } + + const limit = Math.min(options.limit ?? 100, 500); + const offset = Math.max(options.offset ?? 0, 0); + + const rows = await db + .select({ + id: timeTrackingTimesheets.id, + brand_id: timeTrackingTimesheets.brandId, + field_worker_id: timeTrackingTimesheets.fieldWorkerId, + worker_name: fieldWorkers.name, + worker_role: fieldWorkers.role, + period_start: timeTrackingTimesheets.periodStart, + period_end: timeTrackingTimesheets.periodEnd, + status: timeTrackingTimesheets.status, + total_minutes: timeTrackingTimesheets.totalMinutes, + regular_minutes: timeTrackingTimesheets.regularMinutes, + overtime_minutes: timeTrackingTimesheets.overtimeMinutes, + break_minutes: timeTrackingTimesheets.breakMinutes, + entry_count: timeTrackingTimesheets.entryCount, + daily_totals: timeTrackingTimesheets.dailyTotals, + submitted_at: timeTrackingTimesheets.submittedAt, + submitted_notes: timeTrackingTimesheets.submittedNotes, + reviewed_at: timeTrackingTimesheets.reviewedAt, + reviewer_comment: timeTrackingTimesheets.reviewerComment, + rejected_at: timeTrackingTimesheets.rejectedAt, + rejection_reason: timeTrackingTimesheets.rejectionReason, + locked_at: timeTrackingTimesheets.lockedAt, + unlocked_at: timeTrackingTimesheets.unlockedAt, + unlock_audit_note: timeTrackingTimesheets.unlockAuditNote, + updated_at: timeTrackingTimesheets.updatedAt, + }) + .from(timeTrackingTimesheets) + .leftJoin( + fieldWorkers, + eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId), + ) + .where(and(...conds)) + .orderBy(desc(timeTrackingTimesheets.periodStart)) + .limit(limit) + .offset(offset); + + const totalRes = await db + .select({ count: sql`COUNT(*)::int` }) + .from(timeTrackingTimesheets) + .where(and(...conds)); + + return { + rows: rows.map((r) => + rowToSummary({ + ...r, + daily_totals: (r.daily_totals ?? {}) as Record, + reviewed_by_label: null, + }), + ), + total: totalRes[0]?.count ?? 0, + }; + }); +} + +function rowToSummary(r: { + id: string; + brand_id: string; + field_worker_id: string; + worker_name: string | null; + worker_role: string | null; + period_start: string; + period_end: string; + status: TimesheetStatus; + total_minutes: number; + regular_minutes: number; + overtime_minutes: number; + break_minutes: number; + entry_count: number; + daily_totals: Record | null; + submitted_at: Date | null; + submitted_notes: string | null; + reviewed_at: Date | null; + reviewer_comment: string | null; + rejected_at: Date | null; + rejection_reason: string | null; + locked_at: Date | null; + unlocked_at: Date | null; + unlock_audit_note: string | null; + updated_at: Date; + reviewed_by_label: string | null; +}): TimesheetSummary { + return { + id: r.id, + brand_id: r.brand_id, + field_worker_id: r.field_worker_id, + worker_name: r.worker_name ?? "(unknown)", + worker_role: r.worker_role ?? "worker", + period_start: r.period_start, + period_end: r.period_end, + status: r.status, + total_minutes: r.total_minutes, + regular_minutes: r.regular_minutes, + overtime_minutes: r.overtime_minutes, + break_minutes: r.break_minutes, + entry_count: r.entry_count, + daily_totals: r.daily_totals ?? {}, + submitted_at: r.submitted_at ? r.submitted_at.toISOString() : null, + submitted_notes: r.submitted_notes, + reviewed_at: r.reviewed_at ? r.reviewed_at.toISOString() : null, + reviewed_by_label: r.reviewed_by_label, + reviewer_comment: r.reviewer_comment, + rejected_at: r.rejected_at ? r.rejected_at.toISOString() : null, + rejection_reason: r.rejection_reason, + locked_at: r.locked_at ? r.locked_at.toISOString() : null, + unlocked_at: r.unlocked_at ? r.unlocked_at.toISOString() : null, + unlock_audit_note: r.unlock_audit_note, + updated_at: r.updated_at.toISOString(), + }; +} + +// ── Get a single timesheet (with entries + audit) ───────────────────────── + +export async function getTimesheet( + timesheetId: string, +): Promise<{ + summary: TimesheetSummary; + entries: TimesheetEntry[]; + audit: AuditEntry[]; +} | null> { + const caller = await resolveCaller(); + if (!caller) return null; + + return withBrand(caller.brandId, async (db) => { + const [row] = await db + .select({ + id: timeTrackingTimesheets.id, + brand_id: timeTrackingTimesheets.brandId, + field_worker_id: timeTrackingTimesheets.fieldWorkerId, + worker_name: fieldWorkers.name, + worker_role: fieldWorkers.role, + period_start: timeTrackingTimesheets.periodStart, + period_end: timeTrackingTimesheets.periodEnd, + status: timeTrackingTimesheets.status, + total_minutes: timeTrackingTimesheets.totalMinutes, + regular_minutes: timeTrackingTimesheets.regularMinutes, + overtime_minutes: timeTrackingTimesheets.overtimeMinutes, + break_minutes: timeTrackingTimesheets.breakMinutes, + entry_count: timeTrackingTimesheets.entryCount, + daily_totals: timeTrackingTimesheets.dailyTotals, + submitted_at: timeTrackingTimesheets.submittedAt, + submitted_notes: timeTrackingTimesheets.submittedNotes, + reviewed_at: timeTrackingTimesheets.reviewedAt, + reviewer_comment: timeTrackingTimesheets.reviewerComment, + rejected_at: timeTrackingTimesheets.rejectedAt, + rejection_reason: timeTrackingTimesheets.rejectionReason, + locked_at: timeTrackingTimesheets.lockedAt, + unlocked_at: timeTrackingTimesheets.unlockedAt, + unlock_audit_note: timeTrackingTimesheets.unlockAuditNote, + updated_at: timeTrackingTimesheets.updatedAt, + }) + .from(timeTrackingTimesheets) + .leftJoin( + fieldWorkers, + eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId), + ) + .where( + and( + eq(timeTrackingTimesheets.id, timesheetId), + eq(timeTrackingTimesheets.brandId, caller.brandId), + ), + ) + .limit(1); + if (!row) return null; + + // Auth scope check for supervisors + if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { + const assigned = await db + .select({ + id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId, + }) + .from(timeTrackingSupervisorAssignments) + .where( + and( + eq( + timeTrackingSupervisorAssignments.supervisorFieldWorkerId, + caller.supervisorFieldWorkerId, + ), + eq( + timeTrackingSupervisorAssignments.superviseeFieldWorkerId, + row.field_worker_id, + ), + ), + ) + .limit(1); + if (assigned.length === 0) return null; + } + + // Entries + const entryRows = await db + .select({ + id: timeTrackingLogs.id, + brand_id: timeTrackingLogs.brandId, + field_worker_id: timeTrackingLogs.fieldWorkerId, + worker_name: fieldWorkers.name, + task_id: timeTrackingLogs.taskId, + task_name: timeTrackingLogs.taskName, + clock_in: timeTrackingLogs.clockIn, + clock_out: timeTrackingLogs.clockOut, + lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes, + notes: timeTrackingLogs.notes, + submitted_via: timeTrackingLogs.submittedVia, + entry_kind: timeTrackingLogs.entryKind, + manual_reason: timeTrackingLogs.manualReason, + gps_verified: timeTrackingLogs.gpsVerified, + clock_in_lat: timeTrackingLogs.clockInLat, + clock_in_lng: timeTrackingLogs.clockInLng, + clock_in_accuracy_m: timeTrackingLogs.clockInAccuracyM, + clock_out_lat: timeTrackingLogs.clockOutLat, + clock_out_lng: timeTrackingLogs.clockOutLng, + clock_out_accuracy_m: timeTrackingLogs.clockOutAccuracyM, + edited_at: timeTrackingLogs.editedAt, + edit_reason: timeTrackingLogs.editReason, + }) + .from(timeTrackingLogs) + .leftJoin( + fieldWorkers, + eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId), + ) + .where( + and( + eq(timeTrackingLogs.brandId, caller.brandId), + eq(timeTrackingLogs.fieldWorkerId, row.field_worker_id), + gte(timeTrackingLogs.clockIn, new Date(row.period_start)), + lte(timeTrackingLogs.clockIn, endOfDay(row.period_end)), + ), + ) + .orderBy(timeTrackingLogs.clockIn); + + const entryIds = entryRows.map((e) => e.id); + const auditRows = entryIds.length + ? await db + .select() + .from(timeTrackingAuditLog) + .where( + and( + eq(timeTrackingAuditLog.brandId, caller.brandId), + sql`(${timeTrackingAuditLog.entityType} = 'timesheet' AND ${timeTrackingAuditLog.entityId} = ${timesheetId}) OR (${timeTrackingAuditLog.entityType} = 'log' AND ${timeTrackingAuditLog.entityId} IN (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)}))`, + ), + ) + .orderBy(desc(timeTrackingAuditLog.createdAt)) + .limit(200) + : []; + + return { + summary: rowToSummary({ + ...row, + daily_totals: (row.daily_totals ?? {}) as Record, + reviewed_by_label: null, + }), + entries: entryRows.map( + (e): TimesheetEntry => ({ + id: e.id, + brand_id: e.brand_id, + field_worker_id: e.field_worker_id, + worker_name: e.worker_name ?? "(unknown)", + task_id: e.task_id, + task_name: e.task_name, + clock_in: e.clock_in.toISOString(), + clock_out: e.clock_out ? e.clock_out.toISOString() : null, + lunch_break_minutes: e.lunch_break_minutes, + notes: e.notes, + submitted_via: e.submitted_via, + entry_kind: e.entry_kind, + manual_reason: e.manual_reason, + gps_verified: e.gps_verified, + clock_in_lat: e.clock_in_lat, + clock_in_lng: e.clock_in_lng, + clock_in_accuracy_m: e.clock_in_accuracy_m, + clock_out_lat: e.clock_out_lat, + clock_out_lng: e.clock_out_lng, + clock_out_accuracy_m: e.clock_out_accuracy_m, + edited_at: e.edited_at ? e.edited_at.toISOString() : null, + edited_by_label: null, + edit_reason: e.edit_reason, + total_minutes: computeMinutes( + e.clock_in, + e.clock_out, + e.lunch_break_minutes, + ), + }), + ), + audit: auditRows.map((a) => ({ + id: a.id, + actor_id: a.actorId, + actor_label: a.actorLabel, + actor_kind: a.actorKind, + action: a.action, + entity_type: a.entityType, + entity_id: a.entityId, + details: (a.details as Record) ?? {}, + created_at: a.createdAt.toISOString(), + })), + }; + }); +} + +function endOfDay(iso: string): Date { + const d = new Date(iso); + d.setHours(23, 59, 59, 999); + return d; +} + +function computeMinutes( + clockIn: Date, + clockOut: Date | null, + lunchMinutes: number, +): number { + if (!clockOut) return 0; + return Math.max( + 0, + Math.round((clockOut.getTime() - clockIn.getTime()) / 60000) - lunchMinutes, + ); +} + +// ── Ensure the timesheet exists for a (worker, period) ──────────────────── + +export async function ensureTimesheetForPeriod( + brandId: string, + workerId: string, + periodStart: string, + periodEnd: string, +): Promise { + const caller = await resolveCaller(brandId); + if (!caller) throw new Error("Not authenticated"); + + const pool = getPool(); + const res = await pool.query<{ get_or_create_timesheet: string }>( + "SELECT get_or_create_timesheet($1::uuid, $2::uuid, $3::date, $4::date) AS get_or_create_timesheet", + [brandId, workerId, periodStart, periodEnd], + ); + return res.rows[0]?.get_or_create_timesheet ?? ""; +} + +// ── Submit, approve, reject, unlock ──────────────────────────────────────── + +const SubmitSchema = z.object({ + timesheetId: z.string().uuid(), + notes: z.string().max(1000).optional(), +}); + +export async function submitTimesheet( + timesheetId: string, + notes?: string, +): Promise<{ success: boolean; error?: string }> { + const caller = await resolveCaller(); + if (!caller) return { success: false, error: "Not authenticated" }; + + const parsed = SubmitSchema.safeParse({ timesheetId, notes }); + if (!parsed.success) return { success: false, error: "Invalid input" }; + + return withTx(async (client) => { + const ts = await client.query<{ + brand_id: string; + field_worker_id: string; + status: string; + }>( + `SELECT brand_id, field_worker_id, status + FROM time_tracking_timesheets + WHERE id = $1 + FOR UPDATE`, + [timesheetId], + ); + if (ts.rows.length === 0) return { success: false, error: "Not found" }; + const t = ts.rows[0]; + + if (t.status !== "draft" && t.status !== "rejected") { + return { + success: false, + error: `Cannot submit a timesheet in status '${t.status}'`, + }; + } + + await client.query( + `UPDATE time_tracking_timesheets + SET status = 'submitted', + submitted_at = NOW(), + submitted_by = $2, + submitted_notes = $3, + rejected_at = NULL, + rejected_by = NULL, + rejection_reason = NULL, + updated_at = NOW() + WHERE id = $1`, + [timesheetId, caller.adminUser.id, notes ?? null], + ); + + await logTimeTrackingEventInTx(client, { + brandId: t.brand_id, + actorId: caller.adminUser.id, + actorLabel: + caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", + action: "timesheet.submit", + entityType: "timesheet", + entityId: timesheetId, + details: { notes: notes ?? null }, + }); + return { success: true }; + }); +} + +const ApprovalSchema = z.object({ + timesheetId: z.string().uuid(), + comment: z.string().max(2000).optional(), +}); + +export async function approveTimesheet( + timesheetId: string, + comment?: string, +): Promise<{ success: boolean; error?: string }> { + const caller = await resolveCaller(); + if (!caller) return { success: false, error: "Not authenticated" }; + if ( + caller.kind !== "platform_admin" && + caller.kind !== "brand_admin" && + caller.kind !== "supervisor" + ) { + return { success: false, error: "Not authorized" }; + } + + const parsed = ApprovalSchema.safeParse({ timesheetId, comment }); + if (!parsed.success) return { success: false, error: "Invalid input" }; + + return withTx(async (client) => { + const ts = await client.query<{ + brand_id: string; + field_worker_id: string; + status: string; + }>( + `SELECT brand_id, field_worker_id, status + FROM time_tracking_timesheets + WHERE id = $1 + FOR UPDATE`, + [timesheetId], + ); + if (ts.rows.length === 0) return { success: false, error: "Not found" }; + const t = ts.rows[0]; + + // Supervisor must be assigned to this worker + if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { + const ok = await client.query( + `SELECT 1 FROM time_tracking_supervisor_assignments + WHERE supervisor_field_worker_id = $1 + AND supervisee_field_worker_id = $2 + LIMIT 1`, + [caller.supervisorFieldWorkerId, t.field_worker_id], + ); + if (ok.rows.length === 0) + return { success: false, error: "Not authorized for this worker" }; + } + + if (t.status !== "submitted") { + return { + success: false, + error: `Only submitted timesheets can be approved (status='${t.status}')`, + }; + } + + const now = new Date(); + await client.query( + `UPDATE time_tracking_timesheets + SET status = 'approved', + reviewed_at = $2, + reviewed_by = $3, + reviewer_comment = $4, + locked_at = $2, + locked_by = $3, + updated_at = NOW() + WHERE id = $1`, + [timesheetId, now, caller.adminUser.id, comment ?? null], + ); + + await logTimeTrackingEventInTx(client, { + brandId: t.brand_id, + actorId: caller.adminUser.id, + actorLabel: + caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", + action: "timesheet.approve", + entityType: "timesheet", + entityId: timesheetId, + details: { comment: comment ?? null }, + }); + + // Recompute totals so the locked snapshot is accurate + await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]); + + return { success: true }; + }); +} + +const RejectSchema = z.object({ + timesheetId: z.string().uuid(), + reason: z.string().min(3).max(2000), +}); + +export async function rejectTimesheet( + timesheetId: string, + reason: string, +): Promise<{ success: boolean; error?: string }> { + const caller = await resolveCaller(); + if (!caller) return { success: false, error: "Not authenticated" }; + if ( + caller.kind !== "platform_admin" && + caller.kind !== "brand_admin" && + caller.kind !== "supervisor" + ) { + return { success: false, error: "Not authorized" }; + } + + const parsed = RejectSchema.safeParse({ timesheetId, reason }); + if (!parsed.success) return { success: false, error: "Reason is required" }; + + return withTx(async (client) => { + const ts = await client.query<{ + brand_id: string; + field_worker_id: string; + status: string; + }>( + `SELECT brand_id, field_worker_id, status + FROM time_tracking_timesheets + WHERE id = $1 + FOR UPDATE`, + [timesheetId], + ); + if (ts.rows.length === 0) return { success: false, error: "Not found" }; + const t = ts.rows[0]; + + if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) { + const ok = await client.query( + `SELECT 1 FROM time_tracking_supervisor_assignments + WHERE supervisor_field_worker_id = $1 + AND supervisee_field_worker_id = $2 + LIMIT 1`, + [caller.supervisorFieldWorkerId, t.field_worker_id], + ); + if (ok.rows.length === 0) + return { success: false, error: "Not authorized" }; + } + + if (t.status !== "submitted") { + return { + success: false, + error: `Only submitted timesheets can be rejected (status='${t.status}')`, + }; + } + + await client.query( + `UPDATE time_tracking_timesheets + SET status = 'rejected', + rejected_at = NOW(), + rejected_by = $2, + rejection_reason = $3, + locked_at = NULL, + locked_by = NULL, + updated_at = NOW() + WHERE id = $1`, + [timesheetId, caller.adminUser.id, reason], + ); + + await logTimeTrackingEventInTx(client, { + brandId: t.brand_id, + actorId: caller.adminUser.id, + actorLabel: + caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", + action: "timesheet.reject", + entityType: "timesheet", + entityId: timesheetId, + details: { reason }, + }); + + return { success: true }; + }); +} + +const UnlockSchema = z.object({ + timesheetId: z.string().uuid(), + auditNote: z.string().min(5).max(2000), +}); + +export async function unlockTimesheet( + timesheetId: string, + auditNote: string, +): Promise<{ success: boolean; error?: string }> { + const caller = await resolveCaller(); + if (!caller) return { success: false, error: "Not authenticated" }; + + // Only platform_admin or brand_admin can unlock a locked timesheet. + if (caller.kind !== "platform_admin" && caller.kind !== "brand_admin") { + return { + success: false, + error: "Only admins can unlock a locked timesheet", + }; + } + + const parsed = UnlockSchema.safeParse({ timesheetId, auditNote }); + if (!parsed.success) + return { + success: false, + error: "An audit note of at least 5 characters is required", + }; + + return withTx(async (client) => { + const ts = await client.query<{ + brand_id: string; + field_worker_id: string; + status: string; + locked_at: Date | null; + }>( + `SELECT brand_id, field_worker_id, status, locked_at + FROM time_tracking_timesheets + WHERE id = $1 + FOR UPDATE`, + [timesheetId], + ); + if (ts.rows.length === 0) return { success: false, error: "Not found" }; + const t = ts.rows[0]; + + if (!t.locked_at) { + return { success: false, error: "Timesheet is not currently locked" }; + } + + await client.query( + `UPDATE time_tracking_timesheets + SET status = 'draft', + locked_at = NULL, + locked_by = NULL, + unlocked_at = NOW(), + unlocked_by = $2, + unlock_audit_note = $3, + updated_at = NOW() + WHERE id = $1`, + [timesheetId, caller.adminUser.id, auditNote], + ); + + await logTimeTrackingEventInTx(client, { + brandId: t.brand_id, + actorId: caller.adminUser.id, + actorLabel: + caller.adminUser.display_name ?? caller.adminUser.email ?? "admin", + action: "timesheet.unlock", + entityType: "timesheet", + entityId: timesheetId, + details: { audit_note: auditNote }, + }); + + return { success: true }; + }); +} + +// ── Helpers for client components ────────────────────────────────────────── + +/** Compute and return the [start, end] of the current pay period for a brand. */ +export async function getCurrentPayPeriod( + brandId: string, +): Promise<{ start: string; end: string }> { + const { getTimeTrackingSettings } = await import("./index"); + const settings = await getTimeTrackingSettings(brandId); + const { start, end } = payPeriodForDate(new Date(), settings); + return { start: toIsoDate(start), end: toIsoDate(end) }; +} \ No newline at end of file diff --git a/src/app/admin/time-tracking/approvals/page.tsx b/src/app/admin/time-tracking/approvals/page.tsx new file mode 100644 index 0000000..8e5deea --- /dev/null +++ b/src/app/admin/time-tracking/approvals/page.tsx @@ -0,0 +1,89 @@ +/** + * /admin/time-tracking/approvals + * + * Phase 2 — supervisor / admin queue of submitted timesheets. One-click + * approve / reject. Mirrors the "needs review" pattern from the + * orders and stops modules so reviewers don't context-switch. + */ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { listTimesheets } from "@/actions/time-tracking/timesheets"; +import { PageHeader } from "@/components/admin/design-system"; + +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; + +export const dynamic = "force-dynamic"; + +export default async function ApprovalsPage() { + const adminUser = await getAdminUser(); + if (!adminUser) redirect("/login"); + const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID; + + const { rows } = await listTimesheets(brandId, { + status: "submitted", + limit: 100, + }); + + return ( +
+ +
+ {rows.length === 0 ? ( +
+ 🎉 Nothing to review right now. +
+ ) : ( +
    + {rows.map((r) => ( +
  • + +
    +
    + {r.worker_name} + + ({r.worker_role}) + +
    +
    + {r.period_start} → {r.period_end} + {r.submitted_notes && ( + “{r.submitted_notes}” + )} +
    +
    +
    +
    + {Math.floor(r.total_minutes / 60)}h {r.total_minutes % 60}m +
    + {r.overtime_minutes > 0 && ( +
    + + {Math.floor(r.overtime_minutes / 60)}h {r.overtime_minutes % 60}m OT +
    + )} +
    + Submitted{" "} + {r.submitted_at + ? new Date(r.submitted_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + : ""} +
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/time-tracking/audit/page.tsx b/src/app/admin/time-tracking/audit/page.tsx new file mode 100644 index 0000000..751a214 --- /dev/null +++ b/src/app/admin/time-tracking/audit/page.tsx @@ -0,0 +1,148 @@ +/** + * /admin/time-tracking/audit + * + * Phase 2 — read-only audit trail viewer for admins. Shows every + * mutating action on a timesheet, its entries, or the related + * entities, ordered most-recent-first. + */ +import { redirect } from "next/navigation"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { withBrand } from "@/db/client"; +import { timeTrackingAuditLog } from "@/db/schema/time-tracking"; +import { PageHeader } from "@/components/admin/design-system"; +import { desc, eq, sql } from "drizzle-orm"; + +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; + +export const dynamic = "force-dynamic"; + +export default async function AuditPage({ + searchParams, +}: { + searchParams: Promise<{ action?: string; entity?: string }>; +}) { + const adminUser = await getAdminUser(); + if (!adminUser) redirect("/login"); + if (adminUser.role !== "platform_admin" && adminUser.role !== "brand_admin") { + redirect("/admin/time-tracking"); + } + const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID; + const sp = await searchParams; + + const rows = await withBrand(brandId, async (db) => { + const conds = [eq(timeTrackingAuditLog.brandId, brandId)]; + if (sp.action) conds.push(eq(timeTrackingAuditLog.action, sp.action)); + if (sp.entity) conds.push(eq(timeTrackingAuditLog.entityType, sp.entity)); + return db + .select({ + id: timeTrackingAuditLog.id, + actor_label: timeTrackingAuditLog.actorLabel, + actor_kind: timeTrackingAuditLog.actorKind, + action: timeTrackingAuditLog.action, + entity_type: timeTrackingAuditLog.entityType, + entity_id: timeTrackingAuditLog.entityId, + details: timeTrackingAuditLog.details, + created_at: timeTrackingAuditLog.createdAt, + }) + .from(timeTrackingAuditLog) + .where(sql`1=1`) + .orderBy(desc(timeTrackingAuditLog.createdAt)) + .limit(500); + }); + + return ( +
+ +
+
+ + + +
+ +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
WhenActionActorEntityDetails
+ {new Date(r.created_at).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} + + + {r.action} + + + {r.actor_label} + + ({r.actor_kind}) + + + {r.entity_type} + {r.entity_id && ( + {r.entity_id.slice(0, 8)} + )} + +
+                      {JSON.stringify(r.details, null, 0)}
+                    
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/time-tracking/page.tsx b/src/app/admin/time-tracking/page.tsx index b1f20ed..3c994d8 100644 --- a/src/app/admin/time-tracking/page.tsx +++ b/src/app/admin/time-tracking/page.tsx @@ -1,40 +1,88 @@ +import Link from "next/link"; import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel"; +import { FleetRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary"; import { getAdminUser } from "@/lib/admin-permissions"; import { redirect } from "next/navigation"; import { PageHeader } from "@/components/admin/design-system"; +import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"; export const dynamic = "force-dynamic"; -export default async function AdminTimeTrackingPage() { +const TABS = [ + { href: "/admin/time-tracking", label: "Overview" }, + { href: "/admin/time-tracking/timesheets", label: "Timesheets" }, + { href: "/admin/time-tracking/approvals", label: "Approvals" }, + { href: "/admin/time-tracking/audit", label: "Audit" }, +]; + +export default async function AdminTimeTrackingPage({ + searchParams, +}: { + searchParams: Promise<{ tab?: string }>; +}) { const adminUser = await getAdminUser(); + if (!adminUser) redirect("/login"); - if (!adminUser) { - redirect("/login"); - } - - const isPlatformAdmin = adminUser?.role === "platform_admin"; - const isTuxedoAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID; - const isIRDAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === IRD_BRAND_ID; + const isPlatformAdmin = adminUser.role === "platform_admin"; + const isTuxedoAdmin = + adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID; + const isIRDAdmin = + adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID; if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) { redirect("/admin/pickup"); } - const effectiveBrandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID; + const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID; + const sp = await searchParams; + const activeTab = sp.tab ?? "overview"; return (
+ {/* Tab nav */} + + + +
+ +
); } + +async function FleetRouteSummaryClient({ brandId }: { brandId: string }) { + const today = new Date().toISOString().slice(0, 10); + const rows = await getFleetRouteSummary(brandId, today); + return ; +} \ No newline at end of file diff --git a/src/app/admin/time-tracking/timesheets/[id]/page.tsx b/src/app/admin/time-tracking/timesheets/[id]/page.tsx new file mode 100644 index 0000000..5ac0324 --- /dev/null +++ b/src/app/admin/time-tracking/timesheets/[id]/page.tsx @@ -0,0 +1,80 @@ +/** + * /admin/time-tracking/timesheets/[id] + * + * Phase 2 — single timesheet view. Shows the period totals, every + * entry (clock-in / manual), GPS pin status, audit trail, and the + * approval/lock controls. + * + * Workflow buttons rendered conditionally on the caller's role: + * - submit: worker / supervisor when status = draft | rejected + * - approve: supervisor / admin when status = submitted + * - reject: supervisor / admin when status = submitted + * - unlock: admin only, when status = approved + * - manual entry: admin / supervisor, when status != approved + */ +import { redirect, notFound } from "next/navigation"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { + getTimesheet, + getCurrentPayPeriod, + ensureTimesheetForPeriod, +} from "@/actions/time-tracking/timesheets"; +import { getTimeTrackingWorkers } from "@/actions/time-tracking"; +import TimesheetDetail from "@/components/time-tracking/admin/TimesheetDetail"; +import { PageHeader } from "@/components/admin/design-system"; + +export const dynamic = "force-dynamic"; + +export default async function TimesheetDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const adminUser = await getAdminUser(); + if (!adminUser) redirect("/login"); + const { id } = await params; + + // "current" pseudo-id — fetch or create the current pay period for the + // admin's user. Useful as a shortcut from the dashboard. + if (id === "current") { + const brandId = adminUser.brand_id ?? ""; + if (!brandId) redirect("/admin/time-tracking/timesheets"); + const period = await getCurrentPayPeriod(brandId); + const me = adminUser.email; + const workers = await getTimeTrackingWorkers(brandId).catch(() => []); + const mine = workers.find((w) => w.name.toLowerCase() === (me ?? "").toLowerCase()); + if (!mine) redirect("/admin/time-tracking/timesheets"); + const tid = await ensureTimesheetForPeriod(brandId, mine.id, period.start, period.end); + redirect(`/admin/time-tracking/timesheets/${tid}`); + } + + const data = await getTimesheet(id); + if (!data) notFound(); + + const isAdmin = + adminUser.role === "platform_admin" || adminUser.role === "brand_admin"; + + return ( +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/time-tracking/timesheets/page.tsx b/src/app/admin/time-tracking/timesheets/page.tsx new file mode 100644 index 0000000..99f885d --- /dev/null +++ b/src/app/admin/time-tracking/timesheets/page.tsx @@ -0,0 +1,75 @@ +/** + * /admin/time-tracking/timesheets + * + * Phase 2 — list of all timesheets for the brand. Filters by status, + * worker, period. Admins see everything; supervisors see only their + * assigned crew. + */ +import { redirect } from "next/navigation"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { listTimesheets, type TimesheetSummary } from "@/actions/time-tracking/timesheets"; +import { getTimeTrackingWorkers } from "@/actions/time-tracking"; +import TimesheetsList from "@/components/time-tracking/admin/TimesheetsList"; +import { PageHeader } from "@/components/admin/design-system"; + +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; +const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"; + +export const dynamic = "force-dynamic"; + +export default async function TimesheetsPage({ + searchParams, +}: { + searchParams: Promise<{ + status?: string; + workerId?: string; + periodStart?: string; + periodEnd?: string; + }>; +}) { + const adminUser = await getAdminUser(); + if (!adminUser) redirect("/login"); + const sp = await searchParams; + const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID; + + const status = + sp.status === "draft" || + sp.status === "submitted" || + sp.status === "approved" || + sp.status === "rejected" + ? sp.status + : "all"; + + const { rows, total } = await listTimesheets(brandId, { + status, + workerId: sp.workerId, + periodStart: sp.periodStart, + periodEnd: sp.periodEnd, + limit: 200, + }); + + const workers = await getTimeTrackingWorkers(brandId).catch(() => []); + + return ( +
+ +
+ ({ id: w.id, name: w.name, role: w.role }))} + filters={{ + status, + workerId: sp.workerId, + periodStart: sp.periodStart, + periodEnd: sp.periodEnd, + }} + /> +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/water-log/users/[id]/page.tsx b/src/app/admin/water-log/users/[id]/page.tsx index 084b3e5..60f6d92 100644 --- a/src/app/admin/water-log/users/[id]/page.tsx +++ b/src/app/admin/water-log/users/[id]/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth"; import { getWaterIrrigators } from "@/actions/water-log/admin"; import WaterUserEditForm from "@/components/admin/WaterUserEditForm"; +import { DailyRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary"; +import { getRouteTimeSummary } from "@/actions/time-tracking/route-summary"; import Link from "next/link"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; @@ -117,7 +119,25 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag + + ); +} + +async function RouteSummaryCard({ + brandId, + workerId, +}: { + brandId: string; + workerId: string; +}) { + const summary = await getRouteTimeSummary(brandId, workerId); + if (!summary) return null; + return ( +
+ +
+ ); } \ No newline at end of file diff --git a/src/app/api/time-tracking/timesheets/[id]/export/route.ts b/src/app/api/time-tracking/timesheets/[id]/export/route.ts new file mode 100644 index 0000000..0e3f3cf --- /dev/null +++ b/src/app/api/time-tracking/timesheets/[id]/export/route.ts @@ -0,0 +1,59 @@ +/** + * GET /api/time-tracking/timesheets/[id]/export?format=csv|pdf + * + * Returns the timesheet as a CSV or PDF blob. Same authz as the server + * action; the route exists because file downloads from a `` are + * far simpler than marshalling a binary blob through a Server Action + * return value. + */ +import { NextResponse, type NextRequest } from "next/server"; +import { + exportTimesheetCsv, + exportTimesheetPdf, +} from "@/actions/time-tracking/export"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + const url = new URL(req.url); + const format = url.searchParams.get("format") ?? "csv"; + + if (format === "csv") { + const result = await exportTimesheetCsv(id); + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return new NextResponse(result.csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${result.filename}"`, + }, + }); + } + + if (format === "pdf") { + const result = await exportTimesheetPdf(id); + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + // Return the PDF as a binary stream. NextResponse accepts a Buffer. + return new NextResponse(Buffer.from(result.pdf!), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${result.filename}"`, + }, + }); + } + + return NextResponse.json( + { error: `Unsupported format '${format}' (use csv or pdf)` }, + { status: 400 }, + ); +} \ No newline at end of file diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 67c7093..2cab336 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -42,12 +42,54 @@ import { type PayPeriodHours, } from "@/actions/time-tracking/field"; import { FieldPinScreen } from "@/components/field/FieldPinScreen"; -import { Clock, CheckCircle, Spinner, ChevronLeft } from "@/components/water/mobile/icons"; +import { Clock, CheckCircle, Spinner, ChevronLeft, ChevronRight } from "@/components/water/mobile/icons"; const BRAND_PRIMARY = "#14532D"; const AMBER = "#B8761E"; const INK = "#1d1d1f"; +// ── GPS helper ───────────────────────────────────────────────────────────── +// Best-effort geolocation capture for clock-in / clock-out. Returns null +// on any failure (denied permission, insecure context, timeout) — the +// server treats `gps_verified = false` gracefully and the worker is +// never blocked from clocking in/out. + +type GpsSample = { lat: number; lng: number; accuracy_m?: number }; + +function captureGps(timeoutMs = 6000): Promise { + return new Promise((resolve) => { + if (typeof navigator === "undefined" || !navigator.geolocation) { + resolve(null); + return; + } + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(null); + }, timeoutMs); + navigator.geolocation.getCurrentPosition( + (pos) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracy_m: pos.coords.accuracy, + }); + }, + () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(null); + }, + { enableHighAccuracy: true, maximumAge: 30_000, timeout: timeoutMs }, + ); + }); +} + // ── Translations ─────────────────────────────────────────────────────────────── type Translations = { @@ -210,7 +252,26 @@ function taskLabel(task: TimeTaskField, lang: "en" | "es"): string { // ── Screen types ─────────────────────────────────────────────────────────────── -type Screen = "loading" | "pin" | "task_select" | "working" | "clock_out_form" | "clocked_out"; +type Screen = + | "loading" + | "pin" + | "hub" + | "task_select" + | "working" + | "clock_out_form" + | "clocked_out" + | "manual_entry" + | "manual_entry_success" + | "history"; + +// Shape returned by `submitManualTimeLog` — echoed through state to the +// success screen so the worker sees exactly what they entered. +interface ManualEntryResult { + log_id: string; + clock_in: string; + clock_out: string; + total_minutes: number; +} interface OpenEntry { log_id: string; @@ -233,6 +294,20 @@ type State = { notes: string; error: string | null; submitting: boolean; + // Manual-entry scratch state — separate from session-level form fields. + manualEntryDraft: { + date: string; // YYYY-MM-DD + clockInTime: string; // HH:MM + clockOutTime: string; // HH:MM + taskLabel: string; + lunchMinutes: number; + reason: string; + notes: string; + }; + manualEntryResult: ManualEntryResult | null; + manualLogs: WorkerLogRow[]; + manualLogsLoading: boolean; + manualLogsError: string | null; }; type Action = @@ -248,7 +323,30 @@ type Action = | { type: "SET_ERROR"; value: string | null } | { type: "SET_SUBMITTING"; value: boolean } | { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number } - | { type: "RESET_FOR_LOGOUT" }; + | { type: "RESET_FOR_LOGOUT" } + // Manual-entry scratch state + | { type: "SET_MANUAL_DRAFT"; value: State["manualEntryDraft"] } + | { type: "RESET_MANUAL_DRAFT" } + | { type: "SET_MANUAL_ENTRY_RESULT"; value: ManualEntryResult | null } + | { type: "SET_MANUAL_LOGS"; value: WorkerLogRow[] } + | { type: "SET_MANUAL_LOGS_LOADING"; value: boolean } + | { type: "SET_MANUAL_LOGS_ERROR"; value: string | null }; + +function emptyManualDraft(): State["manualEntryDraft"] { + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, "0"); + const dd = String(today.getDate()).padStart(2, "0"); + return { + date: `${yyyy}-${mm}-${dd}`, + clockInTime: "07:00", + clockOutTime: "15:30", + taskLabel: "", + lunchMinutes: 30, + reason: "", + notes: "", + }; +} const initialState: State = { lang: (getLangCookie() as "en" | "es") ?? "en", @@ -262,6 +360,11 @@ const initialState: State = { notes: "", error: null, submitting: false, + manualEntryDraft: emptyManualDraft(), + manualEntryResult: null, + manualLogs: [], + manualLogsLoading: false, + manualLogsError: null, }; function reducer(state: State, action: Action): State { @@ -364,10 +467,12 @@ export default function TimeTrackingFieldClient({ elapsed_minutes: open.elapsed_minutes ?? 0, }, }); - dispatch({ type: "SET_SCREEN", value: "working" }); - } else { - dispatch({ type: "SET_SCREEN", value: "task_select" }); } + // Cycle 12 — land on the Hub. The Hub shows live status (so a + // worker returning with an open shift sees the running timer on + // the hero card) and gives them a chance to use Manual Entry / + // Recent Activity before tapping into the working/clock-out flow. + dispatch({ type: "SET_SCREEN", value: "hub" }); } init(); // intentionally omits screen — see earlier cycle notes @@ -448,7 +553,8 @@ export default function TimeTrackingFieldClient({ const handleSelectTask = async (taskName: string) => { dispatch({ type: "SET_ERROR", value: null }); dispatch({ type: "SET_SUBMITTING", value: true }); - const result = await clockInWorker(brandId, undefined, taskName); + const gps = await captureGps(); + const result = await clockInWorker(brandId, undefined, taskName, gps); dispatch({ type: "SET_SUBMITTING", value: false }); if (!result.success) { if (result.error === "Already clocked in") { @@ -489,7 +595,8 @@ export default function TimeTrackingFieldClient({ const handleClockOut = async () => { dispatch({ type: "SET_ERROR", value: null }); dispatch({ type: "SET_SUBMITTING", value: true }); - const result = await clockOutWorker(lunchMinutes, notes || undefined); + const gps = await captureGps(); + const result = await clockOutWorker(lunchMinutes, notes || undefined, gps); dispatch({ type: "SET_SUBMITTING", value: false }); if (!result.success) { dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" }); diff --git a/src/components/time-tracking/admin/DailyRouteSummary.tsx b/src/components/time-tracking/admin/DailyRouteSummary.tsx new file mode 100644 index 0000000..0570a65 --- /dev/null +++ b/src/components/time-tracking/admin/DailyRouteSummary.tsx @@ -0,0 +1,264 @@ +/** + * DailyRouteSummary — the cross-link surface that shows BOTH water-log + * activity AND time-tracking totals for a single worker-day, side by + * side. This is the "Route summary shows both water totals and time + * totals" deliverable from the brief. + * + * Two flavours: + * - — focused on one worker + * - — table of every worker today + * + * Used by the Water Log admin (per-user detail) and the Time Tracking + * admin (overview tab). The data fetch is delegated to the route-summary + * server action so we only run it once per render. + */ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import type { RouteTimeSummary, WorkerRouteRow } from "@/actions/time-tracking/route-summary"; + +function fmtHM(min: number): string { + if (min <= 0) return "0m"; + const h = Math.floor(min / 60); + const m = min % 60; + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +function fmtGal(g: number): string { + if (g <= 0) return "0 gal"; + if (g >= 1000) return `${(g / 1000).toFixed(g >= 10000 ? 0 : 1)}k gal`; + return `${Math.round(g).toLocaleString()} gal`; +} + +function fmtClockTime(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); +} + +// ── Single-worker view ───────────────────────────────────────────────────── + +export function DailyRouteSummary({ + summary, + compact = false, +}: { + summary: RouteTimeSummary; + compact?: boolean; +}) { + const { water, time } = summary; + const statusTone = + time.clockedIn + ? "bg-emerald-100 text-emerald-800 ring-emerald-300" + : time.workedMinutesToday > 0 + ? "bg-zinc-100 text-zinc-700 ring-zinc-300" + : "bg-amber-50 text-amber-800 ring-amber-300"; + + const tsBadge = (() => { + switch (time.timesheetStatus) { + case "draft": + return { label: "TS · Draft", tone: "bg-zinc-100 text-zinc-700" }; + case "submitted": + return { label: "TS · Submitted", tone: "bg-amber-100 text-amber-800" }; + case "approved": + return { label: "TS · Approved", tone: "bg-emerald-100 text-emerald-800" }; + case "rejected": + return { label: "TS · Rejected", tone: "bg-rose-100 text-rose-800" }; + default: + return null; + } + })(); + + return ( +
+
+
+

+ Daily Route Summary +

+

+ {summary.workerName} +

+
+
+ + {time.clockedIn + ? `On the clock · ${fmtClockTime(time.clockInAt)}` + : time.workedMinutesToday > 0 + ? "Clocked out" + : "Not started"} + + {tsBadge && ( + + {tsBadge.label} + + )} +
+
+ +
+ {/* Water side */} +
+
+ + + +

+ Water Log +

+
+
+
+
Entries
+
{water.entryCount}
+
+
+
Total
+
{fmtGal(water.totalGallons)}
+
+ {!compact && water.headgatesVisited.length > 0 && ( +
+
Headgates
+
+ {water.headgatesVisited.length} unique +
+
+ )} +
+
+ + {/* Time side */} +
+
+ + + + +

+ Time Tracking +

+
+
+
+
Worked
+
{fmtHM(time.workedMinutesToday)}
+
+
+
Regular / OT
+
+ {fmtHM(time.regularMinutesToday)} / {fmtHM(time.overtimeMinutesToday)} +
+
+
+
Break
+
{fmtHM(time.breakMinutesToday)}
+
+
+
+
+ + {!compact && time.timesheetId && ( +
+ + View timesheet → + +
+ )} +
+ ); +} + +// ── Fleet view (table) ───────────────────────────────────────────────────── + +export function FleetRouteSummary({ + brandId, + rows, + date, +}: { + brandId: string; + rows: WorkerRouteRow[]; + date: string; +}) { + return ( +
+
+
+

+ Today's Route +

+

+ Crew — {date} +

+
+ + {rows.filter((r) => r.time.isClockedIn).length} on clock ·{" "} + {rows.length} active + +
+
+ + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r) => ( + + + + + + + + + ))} + +
WorkerWater EntriesGallonsTime TodayClock +
+ No active workers yet. +
{r.workerName}{r.water.entryCount}{fmtGal(r.water.totalGallons)} + {fmtHM(r.time.workedMinutesToday)} + + {r.time.isClockedIn ? ( + + + On + + ) : ( + + Off + + )} + + + View → + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/time-tracking/admin/ManualEntryForm.tsx b/src/components/time-tracking/admin/ManualEntryForm.tsx new file mode 100644 index 0000000..0ceccf8 --- /dev/null +++ b/src/components/time-tracking/admin/ManualEntryForm.tsx @@ -0,0 +1,236 @@ +"use client"; + +/** + * Manual time entry — admin form to add a manual log to a timesheet. + * Captures start, end (optional), break, task, optional GPS, and a + * mandatory reason (required for payroll / labor-law compliance). + */ +import React, { useState, useTransition } from "react"; +import { addManualTimeEntry } from "@/actions/time-tracking/entries"; + +function captureGps(): Promise<{ lat: number; lng: number; accuracy_m?: number } | null> { + return new Promise((resolve) => { + if (typeof navigator === "undefined" || !navigator.geolocation) { + resolve(null); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => + resolve({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracy_m: pos.coords.accuracy, + }), + () => resolve(null), + { enableHighAccuracy: true, timeout: 5000 }, + ); + }); +} + +export default function ManualEntryForm({ + brandId, + workerId, + periodStart, + periodEnd, + onClose, + onCreated, +}: { + brandId: string; + workerId: string; + periodStart: string; + periodEnd: string; + onClose: () => void; + onCreated: (entryId: string) => void; +}) { + const [date, setDate] = useState(periodStart); + const [clockIn, setClockIn] = useState("08:00"); + const [clockOut, setClockOut] = useState("17:00"); + const [hasClockOut, setHasClockOut] = useState(true); + const [breakMin, setBreakMin] = useState(30); + const [taskName, setTaskName] = useState("Field work"); + const [notes, setNotes] = useState(""); + const [reason, setReason] = useState(""); + const [gps, setGps] = useState<{ lat: number; lng: number; accuracy_m?: number } | null>(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function toIso(d: string, t: string): string { + return new Date(`${d}T${t}:00`).toISOString(); + } + + function handleCaptureGps() { + setError(null); + void captureGps().then((g) => { + if (!g) { + setError("Couldn't get GPS — that's OK, you can save without it."); + } else { + setGps(g); + } + }); + } + + function handleSubmit() { + setError(null); + if (!reason || reason.length < 3) { + setError("A manual-entry reason is required (min 3 chars)."); + return; + } + startTransition(async () => { + const r = await addManualTimeEntry({ + brandId, + workerId, + taskName, + clockIn: toIso(date, clockIn), + clockOut: hasClockOut ? toIso(date, clockOut) : null, + lunchBreakMinutes: breakMin, + notes: notes || undefined, + manualReason: reason, + gps: gps ?? undefined, + }); + if (!r.success) { + setError(r.error ?? "Save failed"); + return; + } + onCreated(r.entry_id!); + }); + } + + return ( +
+

+ Add manual entry +

+ +
+ + setDate(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm" + /> + + + setTaskName(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm" + /> + + + setClockIn(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm" + /> + + +
+ setClockOut(e.target.value)} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100" + /> + +
+
+ + setBreakMin(Number(e.target.value))} + className="rounded-md border border-zinc-300 px-3 py-2 text-sm" + /> + + +
+ + {gps && ( + + {gps.lat.toFixed(4)}, {gps.lng.toFixed(4)} + {gps.accuracy_m != null && ` ±${Math.round(gps.accuracy_m)}m`} + + )} +
+
+
+ +
+ + setReason(e.target.value)} + placeholder="Forgot to clock in — was at the barn from 7am" + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm" + /> + +
+ +
+ +