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:
@@ -42,6 +42,12 @@ supabase/.temp/
|
|||||||
# Playwright test results (generated, not source)
|
# Playwright test results (generated, not source)
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
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
|
# IDE / local config
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|||||||
@@ -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(),
|
name: text("name").notNull(),
|
||||||
pinHash: text("pin_hash").notNull(),
|
pinHash: text("pin_hash").notNull(),
|
||||||
role: text("role", {
|
role: text("role", {
|
||||||
enum: ["worker", "time_admin", "irrigator", "water_admin"],
|
enum: ["worker", "time_admin", "irrigator", "water_admin", "driver", "supervisor"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("worker"),
|
.default("worker"),
|
||||||
|
|||||||
+207
-1
@@ -12,6 +12,9 @@
|
|||||||
* `worker_id` column on the downstream tables
|
* `worker_id` column on the downstream tables
|
||||||
* (`time_tracking_logs`, `time_tracking_notification_log`) was
|
* (`time_tracking_logs`, `time_tracking_notification_log`) was
|
||||||
* renamed to `field_worker_id` and the FK repointed.
|
* 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 {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -23,8 +26,10 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
jsonb,
|
jsonb,
|
||||||
|
date,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands, adminUsers } from "./brands";
|
||||||
import { fieldWorkers } from "./field-workers";
|
import { fieldWorkers } from "./field-workers";
|
||||||
|
|
||||||
export const timeTrackingSettings = pgTable(
|
export const timeTrackingSettings = pgTable(
|
||||||
@@ -115,6 +120,32 @@ export const timeTrackingLogs = pgTable(
|
|||||||
submittedVia: text("submitted_via", {
|
submittedVia: text("submitted_via", {
|
||||||
enum: ["manual", "field", "import"],
|
enum: ["manual", "field", "import"],
|
||||||
}).notNull().default("manual"),
|
}).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 })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -125,6 +156,11 @@ export const timeTrackingLogs = pgTable(
|
|||||||
t.fieldWorkerId,
|
t.fieldWorkerId,
|
||||||
),
|
),
|
||||||
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
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;
|
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
||||||
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
|
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
|
||||||
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
|
// `./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 TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||||
export type TimeTrackingNotificationLog =
|
export type TimeTrackingNotificationLog =
|
||||||
typeof timeTrackingNotificationLog.$inferSelect;
|
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) ──────────────────────────────────────────────
|
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,30 @@ interface ClientAction {
|
|||||||
|
|
||||||
async function listClientActions(): Promise<ClientAction[]> {
|
async function listClientActions(): Promise<ClientAction[]> {
|
||||||
|
|
||||||
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: "markOrderReady", handler: markOrderReady },
|
||||||
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||||
// { name: "updateStopStatus", handler: updateStopStatus },
|
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||||
// { name: "adjustProductStock", handler: adjustProductStock },
|
// { 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(
|
export async function dispatchClientAction(
|
||||||
|
|||||||
@@ -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<typeof CreateSchema>,
|
||||||
|
): 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<typeof UpdateSchema>,
|
||||||
|
): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ExportPayload | null> {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
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 { randomUUID } from "node:crypto";
|
||||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import {
|
import {
|
||||||
@@ -152,14 +152,23 @@ export async function verifyTimeTrackingPin(
|
|||||||
|
|
||||||
// ── Clock In ───────────────────────────────────────────────────────────────
|
// ── Clock In ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type GpsSample = {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** Reported accuracy in meters. ≤ 100m = `gps_verified = true`. */
|
||||||
|
accuracy_m?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function clockInWorker(
|
export async function clockInWorker(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
taskId?: string,
|
taskId?: string,
|
||||||
taskName = "General Labor",
|
taskName = "General Labor",
|
||||||
|
gps?: GpsSample | null,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
log_id?: string;
|
log_id?: string;
|
||||||
clock_in?: string;
|
clock_in?: string;
|
||||||
|
gps_verified?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
@@ -199,6 +208,8 @@ export async function clockInWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const gpsVerified =
|
||||||
|
gps != null && (gps.accuracy_m ?? 999) <= 100;
|
||||||
try {
|
try {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(timeTrackingLogs)
|
.insert(timeTrackingLogs)
|
||||||
@@ -209,6 +220,12 @@ export async function clockInWorker(
|
|||||||
taskName: resolvedTaskName,
|
taskName: resolvedTaskName,
|
||||||
clockIn: now,
|
clockIn: now,
|
||||||
submittedVia: "field",
|
submittedVia: "field",
|
||||||
|
// 0098 — GPS + entry_kind
|
||||||
|
entryKind: "clock",
|
||||||
|
clockInLat: gps?.lat ?? null,
|
||||||
|
clockInLng: gps?.lng ?? null,
|
||||||
|
clockInAccuracyM: gps?.accuracy_m ?? null,
|
||||||
|
gpsVerified,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: timeTrackingLogs.id,
|
id: timeTrackingLogs.id,
|
||||||
@@ -220,6 +237,7 @@ export async function clockInWorker(
|
|||||||
success: true,
|
success: true,
|
||||||
log_id: row.id,
|
log_id: row.id,
|
||||||
clock_in: row.clockIn.toISOString(),
|
clock_in: row.clockIn.toISOString(),
|
||||||
|
gps_verified: gpsVerified,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Race lost to a concurrent clock-in: the partial unique index
|
// Race lost to a concurrent clock-in: the partial unique index
|
||||||
@@ -267,11 +285,13 @@ export async function enqueueClockOutForSync(
|
|||||||
export async function clockOutWorker(
|
export async function clockOutWorker(
|
||||||
lunchMinutes = 0,
|
lunchMinutes = 0,
|
||||||
notes?: string,
|
notes?: string,
|
||||||
|
gps?: GpsSample | null,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
log_id?: string;
|
log_id?: string;
|
||||||
clock_out?: string;
|
clock_out?: string;
|
||||||
total_minutes?: number;
|
total_minutes?: number;
|
||||||
|
gps_verified?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
@@ -296,12 +316,22 @@ export async function clockOutWorker(
|
|||||||
const log = open[0];
|
const log = open[0];
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const gpsVerified =
|
||||||
|
gps != null && (gps.accuracy_m ?? 999) <= 100;
|
||||||
await db
|
await db
|
||||||
.update(timeTrackingLogs)
|
.update(timeTrackingLogs)
|
||||||
.set({
|
.set({
|
||||||
clockOut: now,
|
clockOut: now,
|
||||||
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
|
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
|
||||||
notes: notes?.trim() || null,
|
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));
|
.where(eq(timeTrackingLogs.id, log.id));
|
||||||
|
|
||||||
@@ -321,15 +351,40 @@ export async function clockOutWorker(
|
|||||||
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
|
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
|
||||||
Math.max(0, Math.round(lunchMinutes)),
|
Math.max(0, Math.round(lunchMinutes)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Recompute the owning timesheet totals (0098).
|
||||||
|
void recomputeOwningTimesheet(session.brand_id, log.id).catch(() => {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
log_id: log.id,
|
log_id: log.id,
|
||||||
clock_out: now.toISOString(),
|
clock_out: now.toISOString(),
|
||||||
total_minutes: totalMinutes,
|
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<void> {
|
||||||
|
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 ──────────────────────────────────────────────────────
|
// ── Get Open Clock In ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getOpenClockIn(brandId: string): Promise<{
|
export async function getOpenClockIn(brandId: string): Promise<{
|
||||||
@@ -509,3 +564,260 @@ export async function getWorkerPayPeriodHours(
|
|||||||
weekly_threshold: totals.weekly_threshold,
|
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<SubmitManualTimeLogResult> {
|
||||||
|
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<RecentTimeLogsResult> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<RouteTimeSummary | null> {
|
||||||
|
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<WorkerRouteRow[]> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,909 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — Timesheets server actions.
|
||||||
|
*
|
||||||
|
* Cycle 12 (Phase 2): per-worker pay-period timesheets with a Draft →
|
||||||
|
* Submitted → Approved/Locked workflow. Only `platform_admin` /
|
||||||
|
* `brand_admin` can unlock a locked timesheet, and that requires a
|
||||||
|
* mandatory audit note.
|
||||||
|
*
|
||||||
|
* Authorisation model:
|
||||||
|
* - platform_admin / brand_admin — full access across their brand
|
||||||
|
* - supervisor (field_workers.role = 'supervisor') — read + approve
|
||||||
|
* timesheets for assigned supervisees only
|
||||||
|
* - worker (field_workers.role IN ('worker','driver')) — can only
|
||||||
|
* read their own timesheets and submit (draft → submitted); cannot
|
||||||
|
* approve / unlock
|
||||||
|
*
|
||||||
|
* Concurrency:
|
||||||
|
* - All mutations run inside a single `withTx` block so the timesheet
|
||||||
|
* status + the audit row + the recompute RPC commit atomically.
|
||||||
|
*/
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
lte,
|
||||||
|
sql,
|
||||||
|
type SQL,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getPool, withTx } from "@/lib/db";
|
||||||
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
|
import {
|
||||||
|
timeTrackingLogs,
|
||||||
|
timeTrackingTimesheets,
|
||||||
|
timeTrackingSupervisorAssignments,
|
||||||
|
timeTrackingAuditLog,
|
||||||
|
type TimesheetStatus,
|
||||||
|
} from "@/db/schema/time-tracking";
|
||||||
|
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||||
|
import {
|
||||||
|
logTimeTrackingEventInTx,
|
||||||
|
} from "@/lib/time-tracking-audit";
|
||||||
|
import { payPeriodForDate, toIsoDate } from "@/lib/time-tracking/period";
|
||||||
|
|
||||||
|
// ── Period math ────────────────────────────────────────────────────────────
|
||||||
|
// `payPeriodForDate` / `toIsoDate` live in `@/lib/time-tracking/period`
|
||||||
|
// so client components can use them too — server-action files
|
||||||
|
// ("use server") can only export async functions.
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TimesheetEntry = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
worker_name: string;
|
||||||
|
task_id: string | null;
|
||||||
|
task_name: string;
|
||||||
|
clock_in: string;
|
||||||
|
clock_out: string | null;
|
||||||
|
lunch_break_minutes: number;
|
||||||
|
notes: string | null;
|
||||||
|
submitted_via: string;
|
||||||
|
entry_kind: "clock" | "manual";
|
||||||
|
manual_reason: string | null;
|
||||||
|
gps_verified: boolean;
|
||||||
|
clock_in_lat: number | null;
|
||||||
|
clock_in_lng: number | null;
|
||||||
|
clock_in_accuracy_m: number | null;
|
||||||
|
clock_out_lat: number | null;
|
||||||
|
clock_out_lng: number | null;
|
||||||
|
clock_out_accuracy_m: number | null;
|
||||||
|
edited_at: string | null;
|
||||||
|
edited_by_label: string | null;
|
||||||
|
edit_reason: string | null;
|
||||||
|
total_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimesheetSummary = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
worker_name: string;
|
||||||
|
worker_role: string;
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
status: TimesheetStatus;
|
||||||
|
total_minutes: number;
|
||||||
|
regular_minutes: number;
|
||||||
|
overtime_minutes: number;
|
||||||
|
break_minutes: number;
|
||||||
|
entry_count: number;
|
||||||
|
daily_totals: Record<string, number>;
|
||||||
|
submitted_at: string | null;
|
||||||
|
submitted_notes: string | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
reviewed_by_label: string | null;
|
||||||
|
reviewer_comment: string | null;
|
||||||
|
rejected_at: string | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
locked_at: string | null;
|
||||||
|
unlocked_at: string | null;
|
||||||
|
unlock_audit_note: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditEntry = {
|
||||||
|
id: string;
|
||||||
|
actor_id: string | null;
|
||||||
|
actor_label: string;
|
||||||
|
actor_kind: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker";
|
||||||
|
|
||||||
|
type CallerContext = {
|
||||||
|
adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>>;
|
||||||
|
kind: CallerKind;
|
||||||
|
brandId: string;
|
||||||
|
/** When kind === 'supervisor', the field_worker row id of the supervisor. */
|
||||||
|
supervisorFieldWorkerId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the caller's role + brand context. Admins (platform/brand) are
|
||||||
|
* detected by `admin_users.role`. Supervisors are detected by matching
|
||||||
|
* `field_workers.role = 'supervisor'` for the admin's email.
|
||||||
|
*/
|
||||||
|
async function resolveCaller(brandId?: string): Promise<CallerContext | null> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const resolvedBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||||
|
if (!resolvedBrandId) return null;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
return {
|
||||||
|
adminUser,
|
||||||
|
kind: "platform_admin",
|
||||||
|
brandId: resolvedBrandId,
|
||||||
|
supervisorFieldWorkerId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.role === "brand_admin") {
|
||||||
|
return {
|
||||||
|
adminUser,
|
||||||
|
kind: "brand_admin",
|
||||||
|
brandId: resolvedBrandId,
|
||||||
|
supervisorFieldWorkerId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// store_employee or other: lookup as supervisor via email match
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const fw = await db
|
||||||
|
.select()
|
||||||
|
.from(fieldWorkers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fieldWorkers.brandId, adminUser.brand_id ?? ""),
|
||||||
|
sql`LOWER(${fieldWorkers.name}) = LOWER(${adminUser.email ?? ""})`,
|
||||||
|
eq(fieldWorkers.role, "supervisor"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (fw.length > 0) {
|
||||||
|
return {
|
||||||
|
adminUser,
|
||||||
|
kind: "supervisor" as const,
|
||||||
|
brandId: resolvedBrandId,
|
||||||
|
supervisorFieldWorkerId: fw[0].id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adminUser,
|
||||||
|
kind: "worker" as const,
|
||||||
|
brandId: resolvedBrandId,
|
||||||
|
supervisorFieldWorkerId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List timesheets ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listTimesheets(
|
||||||
|
brandId: string,
|
||||||
|
options: {
|
||||||
|
status?: TimesheetStatus | "all";
|
||||||
|
workerId?: string;
|
||||||
|
periodStart?: string; // YYYY-MM-DD
|
||||||
|
periodEnd?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ rows: TimesheetSummary[]; total: number }> {
|
||||||
|
const caller = await resolveCaller(brandId);
|
||||||
|
if (!caller) return { rows: [], total: 0 };
|
||||||
|
|
||||||
|
return withBrand(caller.brandId, async (db) => {
|
||||||
|
const conds: SQL[] = [eq(timeTrackingTimesheets.brandId, caller.brandId)];
|
||||||
|
if (options.status && options.status !== "all") {
|
||||||
|
conds.push(
|
||||||
|
eq(timeTrackingTimesheets.status, options.status as TimesheetStatus),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.workerId) {
|
||||||
|
conds.push(eq(timeTrackingTimesheets.fieldWorkerId, options.workerId));
|
||||||
|
}
|
||||||
|
if (options.periodStart) {
|
||||||
|
conds.push(gte(timeTrackingTimesheets.periodStart, options.periodStart));
|
||||||
|
}
|
||||||
|
if (options.periodEnd) {
|
||||||
|
conds.push(lte(timeTrackingTimesheets.periodEnd, options.periodEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisor scoping: only their assigned supervisees
|
||||||
|
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||||
|
const assigned = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||||
|
})
|
||||||
|
.from(timeTrackingSupervisorAssignments)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
|
||||||
|
caller.supervisorFieldWorkerId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ids = assigned.map((a) => a.id);
|
||||||
|
if (ids.length === 0) return { rows: [], total: 0 };
|
||||||
|
conds.push(inArray(timeTrackingTimesheets.fieldWorkerId, ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(options.limit ?? 100, 500);
|
||||||
|
const offset = Math.max(options.offset ?? 0, 0);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingTimesheets.id,
|
||||||
|
brand_id: timeTrackingTimesheets.brandId,
|
||||||
|
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
|
||||||
|
worker_name: fieldWorkers.name,
|
||||||
|
worker_role: fieldWorkers.role,
|
||||||
|
period_start: timeTrackingTimesheets.periodStart,
|
||||||
|
period_end: timeTrackingTimesheets.periodEnd,
|
||||||
|
status: timeTrackingTimesheets.status,
|
||||||
|
total_minutes: timeTrackingTimesheets.totalMinutes,
|
||||||
|
regular_minutes: timeTrackingTimesheets.regularMinutes,
|
||||||
|
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
|
||||||
|
break_minutes: timeTrackingTimesheets.breakMinutes,
|
||||||
|
entry_count: timeTrackingTimesheets.entryCount,
|
||||||
|
daily_totals: timeTrackingTimesheets.dailyTotals,
|
||||||
|
submitted_at: timeTrackingTimesheets.submittedAt,
|
||||||
|
submitted_notes: timeTrackingTimesheets.submittedNotes,
|
||||||
|
reviewed_at: timeTrackingTimesheets.reviewedAt,
|
||||||
|
reviewer_comment: timeTrackingTimesheets.reviewerComment,
|
||||||
|
rejected_at: timeTrackingTimesheets.rejectedAt,
|
||||||
|
rejection_reason: timeTrackingTimesheets.rejectionReason,
|
||||||
|
locked_at: timeTrackingTimesheets.lockedAt,
|
||||||
|
unlocked_at: timeTrackingTimesheets.unlockedAt,
|
||||||
|
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
|
||||||
|
updated_at: timeTrackingTimesheets.updatedAt,
|
||||||
|
})
|
||||||
|
.from(timeTrackingTimesheets)
|
||||||
|
.leftJoin(
|
||||||
|
fieldWorkers,
|
||||||
|
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
|
||||||
|
)
|
||||||
|
.where(and(...conds))
|
||||||
|
.orderBy(desc(timeTrackingTimesheets.periodStart))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const totalRes = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)::int` })
|
||||||
|
.from(timeTrackingTimesheets)
|
||||||
|
.where(and(...conds));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map((r) =>
|
||||||
|
rowToSummary({
|
||||||
|
...r,
|
||||||
|
daily_totals: (r.daily_totals ?? {}) as Record<string, number>,
|
||||||
|
reviewed_by_label: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
total: totalRes[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToSummary(r: {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
worker_name: string | null;
|
||||||
|
worker_role: string | null;
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
status: TimesheetStatus;
|
||||||
|
total_minutes: number;
|
||||||
|
regular_minutes: number;
|
||||||
|
overtime_minutes: number;
|
||||||
|
break_minutes: number;
|
||||||
|
entry_count: number;
|
||||||
|
daily_totals: Record<string, number> | null;
|
||||||
|
submitted_at: Date | null;
|
||||||
|
submitted_notes: string | null;
|
||||||
|
reviewed_at: Date | null;
|
||||||
|
reviewer_comment: string | null;
|
||||||
|
rejected_at: Date | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
locked_at: Date | null;
|
||||||
|
unlocked_at: Date | null;
|
||||||
|
unlock_audit_note: string | null;
|
||||||
|
updated_at: Date;
|
||||||
|
reviewed_by_label: string | null;
|
||||||
|
}): TimesheetSummary {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
brand_id: r.brand_id,
|
||||||
|
field_worker_id: r.field_worker_id,
|
||||||
|
worker_name: r.worker_name ?? "(unknown)",
|
||||||
|
worker_role: r.worker_role ?? "worker",
|
||||||
|
period_start: r.period_start,
|
||||||
|
period_end: r.period_end,
|
||||||
|
status: r.status,
|
||||||
|
total_minutes: r.total_minutes,
|
||||||
|
regular_minutes: r.regular_minutes,
|
||||||
|
overtime_minutes: r.overtime_minutes,
|
||||||
|
break_minutes: r.break_minutes,
|
||||||
|
entry_count: r.entry_count,
|
||||||
|
daily_totals: r.daily_totals ?? {},
|
||||||
|
submitted_at: r.submitted_at ? r.submitted_at.toISOString() : null,
|
||||||
|
submitted_notes: r.submitted_notes,
|
||||||
|
reviewed_at: r.reviewed_at ? r.reviewed_at.toISOString() : null,
|
||||||
|
reviewed_by_label: r.reviewed_by_label,
|
||||||
|
reviewer_comment: r.reviewer_comment,
|
||||||
|
rejected_at: r.rejected_at ? r.rejected_at.toISOString() : null,
|
||||||
|
rejection_reason: r.rejection_reason,
|
||||||
|
locked_at: r.locked_at ? r.locked_at.toISOString() : null,
|
||||||
|
unlocked_at: r.unlocked_at ? r.unlocked_at.toISOString() : null,
|
||||||
|
unlock_audit_note: r.unlock_audit_note,
|
||||||
|
updated_at: r.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get a single timesheet (with entries + audit) ─────────────────────────
|
||||||
|
|
||||||
|
export async function getTimesheet(
|
||||||
|
timesheetId: string,
|
||||||
|
): Promise<{
|
||||||
|
summary: TimesheetSummary;
|
||||||
|
entries: TimesheetEntry[];
|
||||||
|
audit: AuditEntry[];
|
||||||
|
} | null> {
|
||||||
|
const caller = await resolveCaller();
|
||||||
|
if (!caller) return null;
|
||||||
|
|
||||||
|
return withBrand(caller.brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingTimesheets.id,
|
||||||
|
brand_id: timeTrackingTimesheets.brandId,
|
||||||
|
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
|
||||||
|
worker_name: fieldWorkers.name,
|
||||||
|
worker_role: fieldWorkers.role,
|
||||||
|
period_start: timeTrackingTimesheets.periodStart,
|
||||||
|
period_end: timeTrackingTimesheets.periodEnd,
|
||||||
|
status: timeTrackingTimesheets.status,
|
||||||
|
total_minutes: timeTrackingTimesheets.totalMinutes,
|
||||||
|
regular_minutes: timeTrackingTimesheets.regularMinutes,
|
||||||
|
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
|
||||||
|
break_minutes: timeTrackingTimesheets.breakMinutes,
|
||||||
|
entry_count: timeTrackingTimesheets.entryCount,
|
||||||
|
daily_totals: timeTrackingTimesheets.dailyTotals,
|
||||||
|
submitted_at: timeTrackingTimesheets.submittedAt,
|
||||||
|
submitted_notes: timeTrackingTimesheets.submittedNotes,
|
||||||
|
reviewed_at: timeTrackingTimesheets.reviewedAt,
|
||||||
|
reviewer_comment: timeTrackingTimesheets.reviewerComment,
|
||||||
|
rejected_at: timeTrackingTimesheets.rejectedAt,
|
||||||
|
rejection_reason: timeTrackingTimesheets.rejectionReason,
|
||||||
|
locked_at: timeTrackingTimesheets.lockedAt,
|
||||||
|
unlocked_at: timeTrackingTimesheets.unlockedAt,
|
||||||
|
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
|
||||||
|
updated_at: timeTrackingTimesheets.updatedAt,
|
||||||
|
})
|
||||||
|
.from(timeTrackingTimesheets)
|
||||||
|
.leftJoin(
|
||||||
|
fieldWorkers,
|
||||||
|
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingTimesheets.id, timesheetId),
|
||||||
|
eq(timeTrackingTimesheets.brandId, caller.brandId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// Auth scope check for supervisors
|
||||||
|
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||||
|
const assigned = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||||
|
})
|
||||||
|
.from(timeTrackingSupervisorAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
|
||||||
|
caller.supervisorFieldWorkerId,
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||||
|
row.field_worker_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (assigned.length === 0) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries
|
||||||
|
const entryRows = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingLogs.id,
|
||||||
|
brand_id: timeTrackingLogs.brandId,
|
||||||
|
field_worker_id: timeTrackingLogs.fieldWorkerId,
|
||||||
|
worker_name: fieldWorkers.name,
|
||||||
|
task_id: timeTrackingLogs.taskId,
|
||||||
|
task_name: timeTrackingLogs.taskName,
|
||||||
|
clock_in: timeTrackingLogs.clockIn,
|
||||||
|
clock_out: timeTrackingLogs.clockOut,
|
||||||
|
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
|
||||||
|
notes: timeTrackingLogs.notes,
|
||||||
|
submitted_via: timeTrackingLogs.submittedVia,
|
||||||
|
entry_kind: timeTrackingLogs.entryKind,
|
||||||
|
manual_reason: timeTrackingLogs.manualReason,
|
||||||
|
gps_verified: timeTrackingLogs.gpsVerified,
|
||||||
|
clock_in_lat: timeTrackingLogs.clockInLat,
|
||||||
|
clock_in_lng: timeTrackingLogs.clockInLng,
|
||||||
|
clock_in_accuracy_m: timeTrackingLogs.clockInAccuracyM,
|
||||||
|
clock_out_lat: timeTrackingLogs.clockOutLat,
|
||||||
|
clock_out_lng: timeTrackingLogs.clockOutLng,
|
||||||
|
clock_out_accuracy_m: timeTrackingLogs.clockOutAccuracyM,
|
||||||
|
edited_at: timeTrackingLogs.editedAt,
|
||||||
|
edit_reason: timeTrackingLogs.editReason,
|
||||||
|
})
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.leftJoin(
|
||||||
|
fieldWorkers,
|
||||||
|
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, caller.brandId),
|
||||||
|
eq(timeTrackingLogs.fieldWorkerId, row.field_worker_id),
|
||||||
|
gte(timeTrackingLogs.clockIn, new Date(row.period_start)),
|
||||||
|
lte(timeTrackingLogs.clockIn, endOfDay(row.period_end)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(timeTrackingLogs.clockIn);
|
||||||
|
|
||||||
|
const entryIds = entryRows.map((e) => e.id);
|
||||||
|
const auditRows = entryIds.length
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingAuditLog)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingAuditLog.brandId, caller.brandId),
|
||||||
|
sql`(${timeTrackingAuditLog.entityType} = 'timesheet' AND ${timeTrackingAuditLog.entityId} = ${timesheetId}) OR (${timeTrackingAuditLog.entityType} = 'log' AND ${timeTrackingAuditLog.entityId} IN (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)}))`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(timeTrackingAuditLog.createdAt))
|
||||||
|
.limit(200)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: rowToSummary({
|
||||||
|
...row,
|
||||||
|
daily_totals: (row.daily_totals ?? {}) as Record<string, number>,
|
||||||
|
reviewed_by_label: null,
|
||||||
|
}),
|
||||||
|
entries: entryRows.map(
|
||||||
|
(e): TimesheetEntry => ({
|
||||||
|
id: e.id,
|
||||||
|
brand_id: e.brand_id,
|
||||||
|
field_worker_id: e.field_worker_id,
|
||||||
|
worker_name: e.worker_name ?? "(unknown)",
|
||||||
|
task_id: e.task_id,
|
||||||
|
task_name: e.task_name,
|
||||||
|
clock_in: e.clock_in.toISOString(),
|
||||||
|
clock_out: e.clock_out ? e.clock_out.toISOString() : null,
|
||||||
|
lunch_break_minutes: e.lunch_break_minutes,
|
||||||
|
notes: e.notes,
|
||||||
|
submitted_via: e.submitted_via,
|
||||||
|
entry_kind: e.entry_kind,
|
||||||
|
manual_reason: e.manual_reason,
|
||||||
|
gps_verified: e.gps_verified,
|
||||||
|
clock_in_lat: e.clock_in_lat,
|
||||||
|
clock_in_lng: e.clock_in_lng,
|
||||||
|
clock_in_accuracy_m: e.clock_in_accuracy_m,
|
||||||
|
clock_out_lat: e.clock_out_lat,
|
||||||
|
clock_out_lng: e.clock_out_lng,
|
||||||
|
clock_out_accuracy_m: e.clock_out_accuracy_m,
|
||||||
|
edited_at: e.edited_at ? e.edited_at.toISOString() : null,
|
||||||
|
edited_by_label: null,
|
||||||
|
edit_reason: e.edit_reason,
|
||||||
|
total_minutes: computeMinutes(
|
||||||
|
e.clock_in,
|
||||||
|
e.clock_out,
|
||||||
|
e.lunch_break_minutes,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
audit: auditRows.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
actor_id: a.actorId,
|
||||||
|
actor_label: a.actorLabel,
|
||||||
|
actor_kind: a.actorKind,
|
||||||
|
action: a.action,
|
||||||
|
entity_type: a.entityType,
|
||||||
|
entity_id: a.entityId,
|
||||||
|
details: (a.details as Record<string, unknown>) ?? {},
|
||||||
|
created_at: a.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(iso: string): Date {
|
||||||
|
const d = new Date(iso);
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMinutes(
|
||||||
|
clockIn: Date,
|
||||||
|
clockOut: Date | null,
|
||||||
|
lunchMinutes: number,
|
||||||
|
): number {
|
||||||
|
if (!clockOut) return 0;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((clockOut.getTime() - clockIn.getTime()) / 60000) - lunchMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ensure the timesheet exists for a (worker, period) ────────────────────
|
||||||
|
|
||||||
|
export async function ensureTimesheetForPeriod(
|
||||||
|
brandId: string,
|
||||||
|
workerId: string,
|
||||||
|
periodStart: string,
|
||||||
|
periodEnd: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const caller = await resolveCaller(brandId);
|
||||||
|
if (!caller) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const res = await pool.query<{ get_or_create_timesheet: string }>(
|
||||||
|
"SELECT get_or_create_timesheet($1::uuid, $2::uuid, $3::date, $4::date) AS get_or_create_timesheet",
|
||||||
|
[brandId, workerId, periodStart, periodEnd],
|
||||||
|
);
|
||||||
|
return res.rows[0]?.get_or_create_timesheet ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit, approve, reject, unlock ────────────────────────────────────────
|
||||||
|
|
||||||
|
const SubmitSchema = z.object({
|
||||||
|
timesheetId: z.string().uuid(),
|
||||||
|
notes: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function submitTimesheet(
|
||||||
|
timesheetId: string,
|
||||||
|
notes?: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const caller = await resolveCaller();
|
||||||
|
if (!caller) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
const parsed = SubmitSchema.safeParse({ timesheetId, notes });
|
||||||
|
if (!parsed.success) return { success: false, error: "Invalid input" };
|
||||||
|
|
||||||
|
return withTx(async (client) => {
|
||||||
|
const ts = await client.query<{
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT brand_id, field_worker_id, status
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
[timesheetId],
|
||||||
|
);
|
||||||
|
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||||
|
const t = ts.rows[0];
|
||||||
|
|
||||||
|
if (t.status !== "draft" && t.status !== "rejected") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Cannot submit a timesheet in status '${t.status}'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE time_tracking_timesheets
|
||||||
|
SET status = 'submitted',
|
||||||
|
submitted_at = NOW(),
|
||||||
|
submitted_by = $2,
|
||||||
|
submitted_notes = $3,
|
||||||
|
rejected_at = NULL,
|
||||||
|
rejected_by = NULL,
|
||||||
|
rejection_reason = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[timesheetId, caller.adminUser.id, notes ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
await logTimeTrackingEventInTx(client, {
|
||||||
|
brandId: t.brand_id,
|
||||||
|
actorId: caller.adminUser.id,
|
||||||
|
actorLabel:
|
||||||
|
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||||
|
action: "timesheet.submit",
|
||||||
|
entityType: "timesheet",
|
||||||
|
entityId: timesheetId,
|
||||||
|
details: { notes: notes ?? null },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApprovalSchema = z.object({
|
||||||
|
timesheetId: z.string().uuid(),
|
||||||
|
comment: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function approveTimesheet(
|
||||||
|
timesheetId: string,
|
||||||
|
comment?: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const caller = await resolveCaller();
|
||||||
|
if (!caller) return { success: false, error: "Not authenticated" };
|
||||||
|
if (
|
||||||
|
caller.kind !== "platform_admin" &&
|
||||||
|
caller.kind !== "brand_admin" &&
|
||||||
|
caller.kind !== "supervisor"
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = ApprovalSchema.safeParse({ timesheetId, comment });
|
||||||
|
if (!parsed.success) return { success: false, error: "Invalid input" };
|
||||||
|
|
||||||
|
return withTx(async (client) => {
|
||||||
|
const ts = await client.query<{
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT brand_id, field_worker_id, status
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
[timesheetId],
|
||||||
|
);
|
||||||
|
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||||
|
const t = ts.rows[0];
|
||||||
|
|
||||||
|
// Supervisor must be assigned to this worker
|
||||||
|
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||||
|
const ok = await client.query(
|
||||||
|
`SELECT 1 FROM time_tracking_supervisor_assignments
|
||||||
|
WHERE supervisor_field_worker_id = $1
|
||||||
|
AND supervisee_field_worker_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[caller.supervisorFieldWorkerId, t.field_worker_id],
|
||||||
|
);
|
||||||
|
if (ok.rows.length === 0)
|
||||||
|
return { success: false, error: "Not authorized for this worker" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.status !== "submitted") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Only submitted timesheets can be approved (status='${t.status}')`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await client.query(
|
||||||
|
`UPDATE time_tracking_timesheets
|
||||||
|
SET status = 'approved',
|
||||||
|
reviewed_at = $2,
|
||||||
|
reviewed_by = $3,
|
||||||
|
reviewer_comment = $4,
|
||||||
|
locked_at = $2,
|
||||||
|
locked_by = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[timesheetId, now, caller.adminUser.id, comment ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
await logTimeTrackingEventInTx(client, {
|
||||||
|
brandId: t.brand_id,
|
||||||
|
actorId: caller.adminUser.id,
|
||||||
|
actorLabel:
|
||||||
|
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||||
|
action: "timesheet.approve",
|
||||||
|
entityType: "timesheet",
|
||||||
|
entityId: timesheetId,
|
||||||
|
details: { comment: comment ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recompute totals so the locked snapshot is accurate
|
||||||
|
await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const RejectSchema = z.object({
|
||||||
|
timesheetId: z.string().uuid(),
|
||||||
|
reason: z.string().min(3).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rejectTimesheet(
|
||||||
|
timesheetId: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const caller = await resolveCaller();
|
||||||
|
if (!caller) return { success: false, error: "Not authenticated" };
|
||||||
|
if (
|
||||||
|
caller.kind !== "platform_admin" &&
|
||||||
|
caller.kind !== "brand_admin" &&
|
||||||
|
caller.kind !== "supervisor"
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = RejectSchema.safeParse({ timesheetId, reason });
|
||||||
|
if (!parsed.success) return { success: false, error: "Reason is required" };
|
||||||
|
|
||||||
|
return withTx(async (client) => {
|
||||||
|
const ts = await client.query<{
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT brand_id, field_worker_id, status
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
[timesheetId],
|
||||||
|
);
|
||||||
|
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||||
|
const t = ts.rows[0];
|
||||||
|
|
||||||
|
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||||
|
const ok = await client.query(
|
||||||
|
`SELECT 1 FROM time_tracking_supervisor_assignments
|
||||||
|
WHERE supervisor_field_worker_id = $1
|
||||||
|
AND supervisee_field_worker_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[caller.supervisorFieldWorkerId, t.field_worker_id],
|
||||||
|
);
|
||||||
|
if (ok.rows.length === 0)
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.status !== "submitted") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Only submitted timesheets can be rejected (status='${t.status}')`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE time_tracking_timesheets
|
||||||
|
SET status = 'rejected',
|
||||||
|
rejected_at = NOW(),
|
||||||
|
rejected_by = $2,
|
||||||
|
rejection_reason = $3,
|
||||||
|
locked_at = NULL,
|
||||||
|
locked_by = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[timesheetId, caller.adminUser.id, reason],
|
||||||
|
);
|
||||||
|
|
||||||
|
await logTimeTrackingEventInTx(client, {
|
||||||
|
brandId: t.brand_id,
|
||||||
|
actorId: caller.adminUser.id,
|
||||||
|
actorLabel:
|
||||||
|
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||||
|
action: "timesheet.reject",
|
||||||
|
entityType: "timesheet",
|
||||||
|
entityId: timesheetId,
|
||||||
|
details: { reason },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnlockSchema = z.object({
|
||||||
|
timesheetId: z.string().uuid(),
|
||||||
|
auditNote: z.string().min(5).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function unlockTimesheet(
|
||||||
|
timesheetId: string,
|
||||||
|
auditNote: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const caller = await resolveCaller();
|
||||||
|
if (!caller) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
// Only platform_admin or brand_admin can unlock a locked timesheet.
|
||||||
|
if (caller.kind !== "platform_admin" && caller.kind !== "brand_admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only admins can unlock a locked timesheet",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UnlockSchema.safeParse({ timesheetId, auditNote });
|
||||||
|
if (!parsed.success)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "An audit note of at least 5 characters is required",
|
||||||
|
};
|
||||||
|
|
||||||
|
return withTx(async (client) => {
|
||||||
|
const ts = await client.query<{
|
||||||
|
brand_id: string;
|
||||||
|
field_worker_id: string;
|
||||||
|
status: string;
|
||||||
|
locked_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT brand_id, field_worker_id, status, locked_at
|
||||||
|
FROM time_tracking_timesheets
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
[timesheetId],
|
||||||
|
);
|
||||||
|
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||||
|
const t = ts.rows[0];
|
||||||
|
|
||||||
|
if (!t.locked_at) {
|
||||||
|
return { success: false, error: "Timesheet is not currently locked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE time_tracking_timesheets
|
||||||
|
SET status = 'draft',
|
||||||
|
locked_at = NULL,
|
||||||
|
locked_by = NULL,
|
||||||
|
unlocked_at = NOW(),
|
||||||
|
unlocked_by = $2,
|
||||||
|
unlock_audit_note = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[timesheetId, caller.adminUser.id, auditNote],
|
||||||
|
);
|
||||||
|
|
||||||
|
await logTimeTrackingEventInTx(client, {
|
||||||
|
brandId: t.brand_id,
|
||||||
|
actorId: caller.adminUser.id,
|
||||||
|
actorLabel:
|
||||||
|
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||||
|
action: "timesheet.unlock",
|
||||||
|
entityType: "timesheet",
|
||||||
|
entityId: timesheetId,
|
||||||
|
details: { audit_note: auditNote },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers for client components ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Compute and return the [start, end] of the current pay period for a brand. */
|
||||||
|
export async function getCurrentPayPeriod(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<{ start: string; end: string }> {
|
||||||
|
const { getTimeTrackingSettings } = await import("./index");
|
||||||
|
const settings = await getTimeTrackingSettings(brandId);
|
||||||
|
const { start, end } = payPeriodForDate(new Date(), settings);
|
||||||
|
return { start: toIsoDate(start), end: toIsoDate(end) };
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
|
<PageHeader
|
||||||
|
title="Approval queue"
|
||||||
|
subtitle={`${rows.length} timesheet${rows.length === 1 ? "" : "s"} pending review`}
|
||||||
|
className="px-6 pt-6"
|
||||||
|
/>
|
||||||
|
<div className="px-6 pb-8">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-12 text-center text-sm text-zinc-500">
|
||||||
|
🎉 Nothing to review right now.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link
|
||||||
|
href={`/admin/time-tracking/timesheets/${r.id}`}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">
|
||||||
|
{r.worker_name}
|
||||||
|
<span className="ml-2 text-xs text-zinc-500">
|
||||||
|
({r.worker_role})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-600">
|
||||||
|
{r.period_start} → {r.period_end}
|
||||||
|
{r.submitted_notes && (
|
||||||
|
<span className="ml-2 italic">“{r.submitted_notes}”</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<div className="font-medium tabular-nums text-zinc-900">
|
||||||
|
{Math.floor(r.total_minutes / 60)}h {r.total_minutes % 60}m
|
||||||
|
</div>
|
||||||
|
{r.overtime_minutes > 0 && (
|
||||||
|
<div className="text-xs text-amber-700">
|
||||||
|
+ {Math.floor(r.overtime_minutes / 60)}h {r.overtime_minutes % 60}m OT
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Submitted{" "}
|
||||||
|
{r.submitted_at
|
||||||
|
? new Date(r.submitted_at).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
|
<PageHeader
|
||||||
|
title="Audit trail"
|
||||||
|
subtitle={`Last ${rows.length} events`}
|
||||||
|
className="px-6 pt-6"
|
||||||
|
/>
|
||||||
|
<div className="px-6 pb-8">
|
||||||
|
<form className="mb-4 flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Action
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="action"
|
||||||
|
defaultValue={sp.action ?? ""}
|
||||||
|
placeholder="e.g. timesheet.approve"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Entity type
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name="entity"
|
||||||
|
defaultValue={sp.entity ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="timesheet">timesheet</option>
|
||||||
|
<option value="log">log</option>
|
||||||
|
<option value="worker">worker</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">When</th>
|
||||||
|
<th className="px-4 py-3">Action</th>
|
||||||
|
<th className="px-4 py-3">Actor</th>
|
||||||
|
<th className="px-4 py-3">Entity</th>
|
||||||
|
<th className="px-4 py-3">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-xs text-zinc-500">
|
||||||
|
{new Date(r.created_at).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-700">
|
||||||
|
{r.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-900">
|
||||||
|
{r.actor_label}
|
||||||
|
<span className="ml-1 text-xs text-zinc-500">
|
||||||
|
({r.actor_kind})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-500">
|
||||||
|
{r.entity_type}
|
||||||
|
{r.entity_id && (
|
||||||
|
<span className="ml-1 font-mono">{r.entity_id.slice(0, 8)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<pre className="max-w-md overflow-x-auto rounded bg-zinc-50 px-2 py-1 text-[11px] text-zinc-600">
|
||||||
|
{JSON.stringify(r.details, null, 0)}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,88 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||||
|
import { FleetRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
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 TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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();
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) redirect("/login");
|
||||||
|
|
||||||
if (!adminUser) {
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
redirect("/login");
|
const isTuxedoAdmin =
|
||||||
}
|
adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
|
||||||
|
const isIRDAdmin =
|
||||||
const isPlatformAdmin = adminUser?.role === "platform_admin";
|
adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
|
||||||
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) {
|
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
|
||||||
redirect("/admin/pickup");
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Time Tracking"
|
title="Time Tracking"
|
||||||
subtitle="Manage workers, tasks, and time logs"
|
subtitle="Manage workers, tasks, timesheets, and approvals"
|
||||||
className="px-6 pt-6"
|
className="px-6 pt-6"
|
||||||
/>
|
/>
|
||||||
<div className="px-6 pb-8">
|
<div className="px-6 pb-8">
|
||||||
|
{/* Tab nav */}
|
||||||
|
<nav className="mb-6 flex gap-1 rounded-xl border border-zinc-200 bg-white p-1">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const active =
|
||||||
|
t.href === "/admin/time-tracking"
|
||||||
|
? activeTab === "overview" || t.href === `/admin/time-tracking${activeTab === "overview" ? "" : ""}`
|
||||||
|
: activeTab && t.href.endsWith(activeTab);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={t.href}
|
||||||
|
href={t.href}
|
||||||
|
className={`flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-zinc-900 text-white"
|
||||||
|
: "text-zinc-600 hover:bg-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<FleetRouteSummaryClient brandId={effectiveBrandId} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function FleetRouteSummaryClient({ brandId }: { brandId: string }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const rows = await getFleetRouteSummary(brandId, today);
|
||||||
|
return <FleetRouteSummary brandId={brandId} rows={rows} date={today} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
|
<PageHeader
|
||||||
|
title={`Timesheet — ${data.summary.worker_name}`}
|
||||||
|
subtitle={`${data.summary.period_start} → ${data.summary.period_end}`}
|
||||||
|
className="px-6 pt-6"
|
||||||
|
/>
|
||||||
|
<div className="px-6 pb-8">
|
||||||
|
<TimesheetDetail
|
||||||
|
summary={data.summary}
|
||||||
|
entries={data.entries}
|
||||||
|
audit={data.audit}
|
||||||
|
caller={{
|
||||||
|
id: adminUser.id,
|
||||||
|
role: adminUser.role,
|
||||||
|
display_name: adminUser.display_name,
|
||||||
|
email: adminUser.email,
|
||||||
|
isAdmin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
|
<PageHeader
|
||||||
|
title="Timesheets"
|
||||||
|
subtitle={`${total} timesheet${total === 1 ? "" : "s"}`}
|
||||||
|
className="px-6 pt-6"
|
||||||
|
/>
|
||||||
|
<div className="px-6 pb-8">
|
||||||
|
<TimesheetsList
|
||||||
|
rows={rows as TimesheetSummary[]}
|
||||||
|
total={total}
|
||||||
|
workers={workers.map((w) => ({ id: w.id, name: w.name, role: w.role }))}
|
||||||
|
filters={{
|
||||||
|
status,
|
||||||
|
workerId: sp.workerId,
|
||||||
|
periodStart: sp.periodStart,
|
||||||
|
periodEnd: sp.periodEnd,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||||
import { getWaterIrrigators } from "@/actions/water-log/admin";
|
import { getWaterIrrigators } from "@/actions/water-log/admin";
|
||||||
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
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";
|
import Link from "next/link";
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
@@ -117,7 +119,25 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
|
|||||||
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
|
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RouteSummaryCard brandId={TUXEDO_BRAND_ID} workerId={waterUser.id} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RouteSummaryCard({
|
||||||
|
brandId,
|
||||||
|
workerId,
|
||||||
|
}: {
|
||||||
|
brandId: string;
|
||||||
|
workerId: string;
|
||||||
|
}) {
|
||||||
|
const summary = await getRouteTimeSummary(brandId, workerId);
|
||||||
|
if (!summary) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<DailyRouteSummary summary={summary} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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 `<a href>` 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,12 +42,54 @@ import {
|
|||||||
type PayPeriodHours,
|
type PayPeriodHours,
|
||||||
} from "@/actions/time-tracking/field";
|
} from "@/actions/time-tracking/field";
|
||||||
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
|
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 BRAND_PRIMARY = "#14532D";
|
||||||
const AMBER = "#B8761E";
|
const AMBER = "#B8761E";
|
||||||
const INK = "#1d1d1f";
|
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<GpsSample | null> {
|
||||||
|
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 ───────────────────────────────────────────────────────────────
|
// ── Translations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Translations = {
|
type Translations = {
|
||||||
@@ -210,7 +252,26 @@ function taskLabel(task: TimeTaskField, lang: "en" | "es"): string {
|
|||||||
|
|
||||||
// ── Screen types ───────────────────────────────────────────────────────────────
|
// ── 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 {
|
interface OpenEntry {
|
||||||
log_id: string;
|
log_id: string;
|
||||||
@@ -233,6 +294,20 @@ type State = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
submitting: boolean;
|
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 =
|
type Action =
|
||||||
@@ -248,7 +323,30 @@ type Action =
|
|||||||
| { type: "SET_ERROR"; value: string | null }
|
| { type: "SET_ERROR"; value: string | null }
|
||||||
| { type: "SET_SUBMITTING"; value: boolean }
|
| { type: "SET_SUBMITTING"; value: boolean }
|
||||||
| { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number }
|
| { 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 = {
|
const initialState: State = {
|
||||||
lang: (getLangCookie() as "en" | "es") ?? "en",
|
lang: (getLangCookie() as "en" | "es") ?? "en",
|
||||||
@@ -262,6 +360,11 @@ const initialState: State = {
|
|||||||
notes: "",
|
notes: "",
|
||||||
error: null,
|
error: null,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
|
manualEntryDraft: emptyManualDraft(),
|
||||||
|
manualEntryResult: null,
|
||||||
|
manualLogs: [],
|
||||||
|
manualLogsLoading: false,
|
||||||
|
manualLogsError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
function reducer(state: State, action: Action): State {
|
||||||
@@ -364,10 +467,12 @@ export default function TimeTrackingFieldClient({
|
|||||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
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();
|
init();
|
||||||
// intentionally omits screen — see earlier cycle notes
|
// intentionally omits screen — see earlier cycle notes
|
||||||
@@ -448,7 +553,8 @@ export default function TimeTrackingFieldClient({
|
|||||||
const handleSelectTask = async (taskName: string) => {
|
const handleSelectTask = async (taskName: string) => {
|
||||||
dispatch({ type: "SET_ERROR", value: null });
|
dispatch({ type: "SET_ERROR", value: null });
|
||||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
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 });
|
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
if (result.error === "Already clocked in") {
|
if (result.error === "Already clocked in") {
|
||||||
@@ -489,7 +595,8 @@ export default function TimeTrackingFieldClient({
|
|||||||
const handleClockOut = async () => {
|
const handleClockOut = async () => {
|
||||||
dispatch({ type: "SET_ERROR", value: null });
|
dispatch({ type: "SET_ERROR", value: null });
|
||||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
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 });
|
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
|
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
|
||||||
|
|||||||
@@ -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:
|
||||||
|
* - <DailyRouteSummary worker=... /> — focused on one worker
|
||||||
|
* - <FleetRouteSummary brand=... /> — 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 (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl ring-1 ring-zinc-200 bg-white shadow-sm ${
|
||||||
|
compact ? "p-4" : "p-5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Daily Route Summary
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-0.5 text-base font-semibold text-zinc-900">
|
||||||
|
{summary.workerName}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 ${statusTone}`}
|
||||||
|
>
|
||||||
|
{time.clockedIn
|
||||||
|
? `On the clock · ${fmtClockTime(time.clockInAt)}`
|
||||||
|
: time.workedMinutesToday > 0
|
||||||
|
? "Clocked out"
|
||||||
|
: "Not started"}
|
||||||
|
</span>
|
||||||
|
{tsBadge && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${tsBadge.tone}`}
|
||||||
|
>
|
||||||
|
{tsBadge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
{/* Water side */}
|
||||||
|
<div className="rounded-xl bg-sky-50/60 p-3 ring-1 ring-sky-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4 text-sky-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2.5c4 5 7 8.5 7 12a7 7 0 0 1-14 0c0-3.5 3-7 7-12z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-sky-700">
|
||||||
|
Water Log
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-2 space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sky-900/70">Entries</dt>
|
||||||
|
<dd className="font-semibold text-sky-900">{water.entryCount}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sky-900/70">Total</dt>
|
||||||
|
<dd className="font-semibold text-sky-900">{fmtGal(water.totalGallons)}</dd>
|
||||||
|
</div>
|
||||||
|
{!compact && water.headgatesVisited.length > 0 && (
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<dt className="shrink-0 text-sky-900/70">Headgates</dt>
|
||||||
|
<dd className="truncate text-right text-xs text-sky-900/80" title={water.headgatesVisited.join(", ")}>
|
||||||
|
{water.headgatesVisited.length} unique
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time side */}
|
||||||
|
<div className="rounded-xl bg-violet-50/60 p-3 ring-1 ring-violet-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4 text-violet-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-violet-700">
|
||||||
|
Time Tracking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-2 space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-violet-900/70">Worked</dt>
|
||||||
|
<dd className="font-semibold text-violet-900">{fmtHM(time.workedMinutesToday)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-violet-900/70">Regular / OT</dt>
|
||||||
|
<dd className="font-semibold text-violet-900">
|
||||||
|
{fmtHM(time.regularMinutesToday)} / {fmtHM(time.overtimeMinutesToday)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-violet-900/70">Break</dt>
|
||||||
|
<dd className="font-semibold text-violet-900">{fmtHM(time.breakMinutesToday)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!compact && time.timesheetId && (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<Link
|
||||||
|
href={`/admin/time-tracking/timesheets/${time.timesheetId}`}
|
||||||
|
className="text-xs font-medium text-violet-700 hover:text-violet-900"
|
||||||
|
>
|
||||||
|
View timesheet →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fleet view (table) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FleetRouteSummary({
|
||||||
|
brandId,
|
||||||
|
rows,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
brandId: string;
|
||||||
|
rows: WorkerRouteRow[];
|
||||||
|
date: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Today's Route
|
||||||
|
</p>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">
|
||||||
|
Crew — {date}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-700">
|
||||||
|
{rows.filter((r) => r.time.isClockedIn).length} on clock ·{" "}
|
||||||
|
{rows.length} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-zinc-200 text-sm">
|
||||||
|
<thead className="bg-zinc-50 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">Worker</th>
|
||||||
|
<th className="px-4 py-2 text-right">Water Entries</th>
|
||||||
|
<th className="px-4 py-2 text-right">Gallons</th>
|
||||||
|
<th className="px-4 py-2 text-right">Time Today</th>
|
||||||
|
<th className="px-4 py-2 text-right">Clock</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-6 text-center text-sm text-zinc-500">
|
||||||
|
No active workers yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.workerId} className={!r.active ? "opacity-60" : ""}>
|
||||||
|
<td className="px-4 py-2 font-medium text-zinc-900">{r.workerName}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-zinc-700">{r.water.entryCount}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-sky-700">{fmtGal(r.water.totalGallons)}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-semibold text-violet-700">
|
||||||
|
{fmtHM(r.time.workedMinutesToday)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
{r.time.isClockedIn ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-800">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
|
||||||
|
On
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-semibold text-zinc-600">
|
||||||
|
Off
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/water-log/users/${r.workerId}?from=/admin/water-log`}
|
||||||
|
className="text-xs font-medium text-zinc-600 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Add manual entry
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<Field label="Date">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={periodStart}
|
||||||
|
max={periodEnd}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Task">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={taskName}
|
||||||
|
onChange={(e) => setTaskName(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Clock in">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={clockIn}
|
||||||
|
onChange={(e) => setClockIn(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Clock out">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={clockOut}
|
||||||
|
disabled={!hasClockOut}
|
||||||
|
onChange={(e) => setClockOut(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasClockOut}
|
||||||
|
onChange={(e) => setHasClockOut(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Has clock-out
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Break (minutes)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={720}
|
||||||
|
value={breakMin}
|
||||||
|
onChange={(e) => setBreakMin(Number(e.target.value))}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="GPS (optional)">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCaptureGps}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Capture current location
|
||||||
|
</button>
|
||||||
|
{gps && (
|
||||||
|
<span className="text-xs text-zinc-600">
|
||||||
|
{gps.lat.toFixed(4)}, {gps.lng.toFixed(4)}
|
||||||
|
{gps.accuracy_m != null && ` ±${Math.round(gps.accuracy_m)}m`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field label="Manual entry reason (required)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Saving…" : "Add entry"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timesheet detail — totals card, entries table, audit trail, and
|
||||||
|
* the approval workflow buttons (submit / approve / reject / unlock /
|
||||||
|
* add manual entry).
|
||||||
|
*
|
||||||
|
* Status-driven UI:
|
||||||
|
* - draft / rejected: "Submit for review" button + manual entry editor
|
||||||
|
* - submitted: "Approve" / "Reject" buttons (supervisor/admin)
|
||||||
|
* - approved (locked): "Unlock" button (admin only) + locked banner
|
||||||
|
*/
|
||||||
|
import React, { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
submitTimesheet,
|
||||||
|
approveTimesheet,
|
||||||
|
rejectTimesheet,
|
||||||
|
unlockTimesheet,
|
||||||
|
} from "@/actions/time-tracking/timesheets";
|
||||||
|
import { deleteTimeEntry } from "@/actions/time-tracking/entries";
|
||||||
|
import type { TimesheetStatus } from "@/db/schema/time-tracking";
|
||||||
|
import type {
|
||||||
|
TimesheetSummary,
|
||||||
|
TimesheetEntry,
|
||||||
|
AuditEntry,
|
||||||
|
} from "@/actions/time-tracking/timesheets";
|
||||||
|
import ManualEntryForm from "./ManualEntryForm";
|
||||||
|
|
||||||
|
type Caller = {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
display_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusColor(s: TimesheetStatus): {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
label: string;
|
||||||
|
} {
|
||||||
|
switch (s) {
|
||||||
|
case "draft":
|
||||||
|
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
|
||||||
|
case "submitted":
|
||||||
|
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
|
||||||
|
case "approved":
|
||||||
|
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
|
||||||
|
case "rejected":
|
||||||
|
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = s;
|
||||||
|
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHM(min: number): string {
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTs(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimesheetDetail({
|
||||||
|
summary,
|
||||||
|
entries,
|
||||||
|
audit,
|
||||||
|
caller,
|
||||||
|
}: {
|
||||||
|
summary: TimesheetSummary;
|
||||||
|
entries: TimesheetEntry[];
|
||||||
|
audit: AuditEntry[];
|
||||||
|
caller: Caller;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [unlockNote, setUnlockNote] = useState("");
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isLocked = !!summary.locked_at;
|
||||||
|
const canSubmit =
|
||||||
|
!isLocked && (summary.status === "draft" || summary.status === "rejected");
|
||||||
|
const canApprove =
|
||||||
|
!isLocked &&
|
||||||
|
summary.status === "submitted" &&
|
||||||
|
(caller.isAdmin || caller.role === "supervisor");
|
||||||
|
const canUnlock = isLocked && caller.isAdmin;
|
||||||
|
const canEditEntries = !isLocked && caller.isAdmin;
|
||||||
|
|
||||||
|
function call(action: () => Promise<{ success: boolean; error?: string }>) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const r = await action();
|
||||||
|
if (!r.success) setError(r.error ?? "Action failed");
|
||||||
|
else router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = statusColor(summary.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top bar: status + actions */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-3 py-1 text-xs font-semibold ${c.bg} ${c.fg}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
{summary.submitted_at && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Submitted {fmtTs(summary.submitted_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.reviewed_at && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Reviewed by {summary.reviewed_by_label ?? "admin"}{" "}
|
||||||
|
{fmtTs(summary.reviewed_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.unlocked_at && summary.unlock_audit_note && (
|
||||||
|
<span className="text-xs italic text-rose-600">
|
||||||
|
Unlocked {fmtTs(summary.unlocked_at)} · “{summary.unlock_audit_note}”
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={`/api/time-tracking/timesheets/${summary.id}/export?format=csv`}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api/time-tracking/timesheets/${summary.id}/export?format=pdf`}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</a>
|
||||||
|
{canEditEntries && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManual((v) => !v)}
|
||||||
|
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{showManual ? "Hide manual entry" : "+ Manual entry"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLocked && (
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||||
|
<strong>Locked.</strong> Entries below are read-only.{" "}
|
||||||
|
{canUnlock
|
||||||
|
? "Use Unlock to make changes."
|
||||||
|
: "Ask an admin to unlock if a correction is required."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
<Stat label="Total" value={formatHM(summary.total_minutes)} />
|
||||||
|
<Stat label="Regular" value={formatHM(summary.regular_minutes)} />
|
||||||
|
<Stat label="Overtime" value={formatHM(summary.overtime_minutes)} />
|
||||||
|
<Stat label="Break" value={formatHM(summary.break_minutes)} />
|
||||||
|
<Stat label="Entries" value={String(summary.entry_count)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual entry editor */}
|
||||||
|
{showManual && canEditEntries && (
|
||||||
|
<ManualEntryForm
|
||||||
|
brandId={summary.brand_id}
|
||||||
|
workerId={summary.field_worker_id}
|
||||||
|
periodStart={summary.period_start}
|
||||||
|
periodEnd={summary.period_end}
|
||||||
|
onClose={() => setShowManual(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowManual(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approval actions */}
|
||||||
|
{(canSubmit || canApprove || canUnlock) && (
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Workflow
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{canSubmit && (
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<label className="text-xs text-zinc-500">
|
||||||
|
Notes for reviewer (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="e.g. includes a 2h drive to second field"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => call(() => submitTimesheet(summary.id, comment || undefined))}
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Submit for review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canApprove && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<label className="text-xs text-zinc-500">
|
||||||
|
Approver comment (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Looks good"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
call(() => approveTimesheet(summary.id, comment || undefined))
|
||||||
|
}
|
||||||
|
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Approve & lock
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (rejectReason.length < 3) {
|
||||||
|
setError("A rejection reason (min 3 chars) is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
call(() => rejectTimesheet(summary.id, rejectReason));
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<label className="text-xs text-zinc-500">
|
||||||
|
Rejection reason (required if rejecting)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
placeholder="Tuesday needs lunch break added"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canUnlock && (
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<label className="text-xs font-semibold text-rose-700">
|
||||||
|
Unlock audit note (required, min 5 chars)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={unlockNote}
|
||||||
|
onChange={(e) => setUnlockNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Payroll found an under-reported hour on Wednesday. Re-locking after correction."
|
||||||
|
className="rounded-md border border-rose-300 px-3 py-2 text-sm"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={pending || unlockNote.length < 5}
|
||||||
|
onClick={() => call(() => unlockTimesheet(summary.id, unlockNote))}
|
||||||
|
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Unlock timesheet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||||
|
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
|
||||||
|
Entries ({entries.length})
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-zinc-500">
|
||||||
|
No entries yet for this period.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-white text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Date</th>
|
||||||
|
<th className="px-4 py-3">Task</th>
|
||||||
|
<th className="px-4 py-3">Clock in / out</th>
|
||||||
|
<th className="px-4 py-3 text-right">Break</th>
|
||||||
|
<th className="px-4 py-3 text-right">Total</th>
|
||||||
|
<th className="px-4 py-3">GPS</th>
|
||||||
|
<th className="px-4 py-3">Kind</th>
|
||||||
|
{canEditEntries && <th className="px-4 py-3 text-right">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="px-4 py-3 text-zinc-700">
|
||||||
|
{e.clock_in.slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-900">{e.task_name}</td>
|
||||||
|
<td className="px-4 py-3 tabular-nums text-zinc-700">
|
||||||
|
{e.clock_in.slice(11, 16)} →{" "}
|
||||||
|
{e.clock_out ? e.clock_out.slice(11, 16) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||||
|
{e.lunch_break_minutes}m
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium tabular-nums text-zinc-900">
|
||||||
|
{formatHM(e.total_minutes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{e.gps_verified ? (
|
||||||
|
<span title="GPS verified (≤100m)">📍</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{e.entry_kind === "manual" ? (
|
||||||
|
<span
|
||||||
|
title={e.manual_reason ?? ""}
|
||||||
|
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||||
|
>
|
||||||
|
manual
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-zinc-500">clock</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canEditEntries && (
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{confirmDelete === e.id ? (
|
||||||
|
<span className="space-x-2">
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
call(async () => {
|
||||||
|
const r = await deleteTimeEntry(
|
||||||
|
e.id,
|
||||||
|
"Removed from timesheet detail page",
|
||||||
|
);
|
||||||
|
return r;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded bg-rose-600 px-2 py-0.5 text-[11px] font-medium text-white"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="rounded border border-zinc-300 px-2 py-0.5 text-[11px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(e.id)}
|
||||||
|
className="text-[11px] text-rose-600 hover:underline"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit trail */}
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||||
|
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
|
||||||
|
Audit trail ({audit.length})
|
||||||
|
</div>
|
||||||
|
{audit.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-zinc-500">No audit entries.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-100">
|
||||||
|
{audit.map((a) => (
|
||||||
|
<li key={a.id} className="px-4 py-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
|
||||||
|
{a.action}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-zinc-900">{a.actor_label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{fmtTs(a.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Object.keys(a.details).length > 0 && (
|
||||||
|
<pre className="mt-1 overflow-x-auto rounded bg-zinc-50 px-3 py-2 text-[11px] text-zinc-600">
|
||||||
|
{JSON.stringify(a.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link
|
||||||
|
href="/admin/time-tracking/timesheets"
|
||||||
|
className="text-zinc-600 hover:underline"
|
||||||
|
>
|
||||||
|
← Back to timesheets
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-white p-3">
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-900">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timesheets list — status filter, worker filter, period filter,
|
||||||
|
* per-row summary + click-through to the detail page. Mobile-friendly.
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { TimesheetStatus } from "@/db/schema/time-tracking";
|
||||||
|
import type { TimesheetSummary } from "@/actions/time-tracking/timesheets";
|
||||||
|
|
||||||
|
type Worker = { id: string; name: string; role: string };
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
status: TimesheetStatus | "all";
|
||||||
|
workerId?: string;
|
||||||
|
periodStart?: string;
|
||||||
|
periodEnd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusColor(s: TimesheetStatus): {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
label: string;
|
||||||
|
} {
|
||||||
|
switch (s) {
|
||||||
|
case "draft":
|
||||||
|
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
|
||||||
|
case "submitted":
|
||||||
|
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
|
||||||
|
case "approved":
|
||||||
|
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
|
||||||
|
case "rejected":
|
||||||
|
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
|
||||||
|
default: {
|
||||||
|
// exhaustiveness guard
|
||||||
|
const _exhaustive: never = s;
|
||||||
|
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHM(min: number): string {
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimesheetsList({
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
workers,
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
rows: TimesheetSummary[];
|
||||||
|
total: number;
|
||||||
|
workers: Worker[];
|
||||||
|
filters: Filters;
|
||||||
|
}) {
|
||||||
|
const workerById = useMemo(() => {
|
||||||
|
const m = new Map<string, Worker>();
|
||||||
|
for (const w of workers) m.set(w.id, w);
|
||||||
|
return m;
|
||||||
|
}, [workers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filter bar */}
|
||||||
|
<form className="flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={filters.status}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="approved">Approved / Locked</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Worker
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="workerId"
|
||||||
|
defaultValue={filters.workerId ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All workers</option>
|
||||||
|
{workers.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.name} ({w.role})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Period start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="periodStart"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filters.periodStart ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
Period end
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="periodEnd"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filters.periodEnd ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/admin/time-tracking/timesheets"
|
||||||
|
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-10 text-center text-sm text-zinc-500">
|
||||||
|
No timesheets match the current filters.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Worker</th>
|
||||||
|
<th className="px-4 py-3">Period</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3 text-right">Total</th>
|
||||||
|
<th className="px-4 py-3 text-right">OT</th>
|
||||||
|
<th className="px-4 py-3 text-right">Entries</th>
|
||||||
|
<th className="px-4 py-3 text-right">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const c = statusColor(r.status);
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/admin/time-tracking/timesheets/${r.id}`}
|
||||||
|
className="font-medium text-zinc-900 hover:underline"
|
||||||
|
>
|
||||||
|
{r.worker_name}
|
||||||
|
</Link>
|
||||||
|
<span className="ml-2 text-xs text-zinc-500">
|
||||||
|
{r.worker_role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-700">
|
||||||
|
{r.period_start} → {r.period_end}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold ${c.bg} ${c.fg}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||||
|
{formatHM(r.total_minutes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||||
|
{formatHM(r.overtime_minutes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||||
|
{r.entry_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-xs text-zinc-500">
|
||||||
|
{new Date(r.updated_at).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — Offline queue helpers.
|
||||||
|
*
|
||||||
|
* The field app runs in places with patchy cellular (rural Colorado
|
||||||
|
* valley floors). When `clockInWorker` / `clockOutWorker` fail because
|
||||||
|
* the device is offline, the action is enqueued here and retried by
|
||||||
|
* the standard offline dispatcher (`src/actions/offline-dispatcher.ts`).
|
||||||
|
*
|
||||||
|
* The action envelope matches the rest of the queue: an `actionName`
|
||||||
|
* plus a JSON-serialisable `payload`. Server-side replay routes the
|
||||||
|
* payload back to the same field actions.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { enqueueAction, getQueuedActions } from "./queue";
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return (crypto as Crypto).randomUUID();
|
||||||
|
}
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueuedClockEvent =
|
||||||
|
| {
|
||||||
|
kind: "clock_in";
|
||||||
|
brandId: string;
|
||||||
|
taskName: string;
|
||||||
|
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||||
|
/** Wall-clock time at which the worker hit the button. */
|
||||||
|
clientTs: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "clock_out";
|
||||||
|
brandId: string;
|
||||||
|
lunchMinutes: number;
|
||||||
|
notes?: string;
|
||||||
|
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||||
|
clientTs: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try the action; on network failure, enqueue it. Resolves with the
|
||||||
|
* action's normal success payload, or `{ queued: true, queueId }` when
|
||||||
|
* the call fell back to the offline queue.
|
||||||
|
*/
|
||||||
|
export async function tryOrQueueClock(
|
||||||
|
event: QueuedClockEvent,
|
||||||
|
liveCall: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
log_id?: string;
|
||||||
|
clock_in?: string;
|
||||||
|
clock_out?: string;
|
||||||
|
total_minutes?: number;
|
||||||
|
error?: string;
|
||||||
|
}>,
|
||||||
|
): Promise<
|
||||||
|
| { success: true; log_id?: string; queued: false }
|
||||||
|
| { success: false; queued: false; error: string }
|
||||||
|
| { success: true; queued: true; queueId: string }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const r = await liveCall();
|
||||||
|
if (r.success) return { success: true, log_id: r.log_id, queued: false };
|
||||||
|
return { success: false, queued: false, error: r.error ?? "Failed" };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!/network|fetch|failed to fetch|offline/i.test(msg)) {
|
||||||
|
return { success: false, queued: false, error: msg };
|
||||||
|
}
|
||||||
|
// Genuine offline — enqueue for later replay.
|
||||||
|
const queueId = uuid();
|
||||||
|
await enqueueAction(
|
||||||
|
event.kind === "clock_in" ? "timeTracking.clockIn" : "timeTracking.clockOut",
|
||||||
|
event,
|
||||||
|
{ id: queueId },
|
||||||
|
);
|
||||||
|
return { success: true, queued: true, queueId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inspect the queue for any pending clock events. Surfaced in the
|
||||||
|
* admin "Recent Activity" panel as "X events pending sync". */
|
||||||
|
export async function pendingClockEvents(): Promise<number> {
|
||||||
|
const rows = await getQueuedActions();
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.actionName === "timeTracking.clockIn" ||
|
||||||
|
r.actionName === "timeTracking.clockOut",
|
||||||
|
).length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — Audit Log Helper
|
||||||
|
*
|
||||||
|
* Append-only audit trail. Every mutating server action on a timesheet,
|
||||||
|
* its entries, or its approval state MUST call `logTimeTrackingEvent` in
|
||||||
|
* the same DB transaction as the mutation so the trail can never drift
|
||||||
|
* from reality.
|
||||||
|
*
|
||||||
|
* Actions follow a dotted-namespace convention so the audit UI can group
|
||||||
|
* them:
|
||||||
|
* - `timesheet.submit` / `.approve` / `.reject` / `.unlock`
|
||||||
|
* - `entry.create` / `.edit` / `.delete` / `.clock_in` / `.clock_out`
|
||||||
|
* - `worker.create` / `.edit` / `.delete`
|
||||||
|
* - `settings.update`
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
|
import { timeTrackingAuditLog } from "@/db/schema/time-tracking";
|
||||||
|
|
||||||
|
export type ActorKind = "admin" | "worker" | "system";
|
||||||
|
|
||||||
|
export type TimeTrackingAuditEvent = {
|
||||||
|
brandId: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorLabel: string;
|
||||||
|
actorKind?: ActorKind;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function logTimeTrackingEvent(
|
||||||
|
event: TimeTrackingAuditEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
await withBrand(event.brandId, async (db) => {
|
||||||
|
await db.insert(timeTrackingAuditLog).values({
|
||||||
|
brandId: event.brandId,
|
||||||
|
actorId: event.actorId,
|
||||||
|
actorLabel: event.actorLabel,
|
||||||
|
actorKind: event.actorKind ?? "admin",
|
||||||
|
action: event.action,
|
||||||
|
entityType: event.entityType,
|
||||||
|
entityId: event.entityId ?? null,
|
||||||
|
details: event.details ?? {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant that runs inside an existing transaction client. Use this from
|
||||||
|
* server actions that batch multiple writes — keeps the audit row and the
|
||||||
|
* mutation atomic.
|
||||||
|
*/
|
||||||
|
export async function logTimeTrackingEventInTx(
|
||||||
|
client: import("pg").PoolClient,
|
||||||
|
event: TimeTrackingAuditEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO time_tracking_audit_log
|
||||||
|
(brand_id, actor_id, actor_label, actor_kind, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
||||||
|
[
|
||||||
|
event.brandId,
|
||||||
|
event.actorId,
|
||||||
|
event.actorLabel,
|
||||||
|
event.actorKind ?? "admin",
|
||||||
|
event.action,
|
||||||
|
event.entityType,
|
||||||
|
event.entityId ?? null,
|
||||||
|
JSON.stringify(event.details ?? {}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid an unused-import warning when callers only use the tx variant.
|
||||||
|
void withPlatformAdmin;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Period-math helpers for time-tracking.
|
||||||
|
*
|
||||||
|
* Lives OUTSIDE `src/actions/time-tracking/timesheets.ts` because that
|
||||||
|
* file uses the "use server" directive, which requires every export
|
||||||
|
* to be an async function. Pure date math doesn't need to be a server
|
||||||
|
* action — it can run on the client too (e.g. for date pickers).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the pay-period [start, end] (inclusive) that contains the
|
||||||
|
* given date, given a brand's settings. Falls back to weekly periods
|
||||||
|
* starting Sunday when settings aren't configured.
|
||||||
|
*/
|
||||||
|
export function payPeriodForDate(
|
||||||
|
date: Date,
|
||||||
|
settings: {
|
||||||
|
pay_period_start_day: number;
|
||||||
|
pay_period_length_days: number;
|
||||||
|
} | null,
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const len = settings?.pay_period_length_days ?? 7;
|
||||||
|
|
||||||
|
if (len === 7) {
|
||||||
|
// Weekly — anchor to Sunday
|
||||||
|
const start = new Date(date);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
start.setDate(date.getDate() - date.getDay());
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 6);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi-monthly / monthly: anchor by day-of-month
|
||||||
|
const anchor = settings?.pay_period_start_day ?? 1;
|
||||||
|
const start = new Date(date.getFullYear(), date.getMonth(), anchor);
|
||||||
|
if (start > date) start.setMonth(start.getMonth() - 1);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + len - 1);
|
||||||
|
if (end < date) {
|
||||||
|
start.setMonth(start.getMonth() + 1);
|
||||||
|
end.setMonth(end.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in UTC. */
|
||||||
|
export function toIsoDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user