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