Files
route-commerce/db/migrations/0098_time_tracking_timesheets.sql
T
Nora 9e6accb3df feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.

Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
  the existing worker / time_admin / irrigator / water_admin values.

Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
  + entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
  rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
  get_or_create_timesheet(), plus a time_tracking_approval_queue view.

Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
  worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
  all run inside withTx with FOR UPDATE row locks so the status + audit
  row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
  the action layer; only unlockTimesheet (admin-only, mandatory audit
  note) can re-open it.

Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
  audit-log writes in the same transaction, lock-aware.

GPS + offline (src/actions/time-tracking/field.ts,
  src/lib/offline/time-tracking-queue.ts,
  src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
  clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
  in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
  replay, submitted_via = 'offline_sync').

Export (src/actions/time-tracking/export.ts +
  src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
  available for any approved timesheet.

UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
  Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
  manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
  water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
  cross-link badge (water gallons + time hours + timesheet status).

Verification
- npx tsc --noEmit  → 0 errors.
- npm run build     → all 10 time-tracking routes compile.
- npm run lint      → 0 errors/warnings in new files.
- Playwright smoke  → 4/5 pass; the failing case is a pre-existing
  Tuxedo storefront 404 unrelated to this change.
2026-07-04 00:27:25 -06:00

443 lines
20 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- 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;