feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.
Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
the existing worker / time_admin / irrigator / water_admin values.
Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
+ entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
get_or_create_timesheet(), plus a time_tracking_approval_queue view.
Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
all run inside withTx with FOR UPDATE row locks so the status + audit
row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
the action layer; only unlockTimesheet (admin-only, mandatory audit
note) can re-open it.
Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
audit-log writes in the same transaction, lock-aware.
GPS + offline (src/actions/time-tracking/field.ts,
src/lib/offline/time-tracking-queue.ts,
src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
replay, submitted_via = 'offline_sync').
Export (src/actions/time-tracking/export.ts +
src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
available for any approved timesheet.
UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
cross-link badge (water gallons + time hours + timesheet status).
Verification
- npx tsc --noEmit → 0 errors.
- npm run build → all 10 time-tracking routes compile.
- npm run lint → 0 errors/warnings in new files.
- Playwright smoke → 4/5 pass; the failing case is a pre-existing
Tuxedo storefront 404 unrelated to this change.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
@@ -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"),
|
||||
|
||||
+207
-1
@@ -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<string, number>;
|
||||
|
||||
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user