Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce652ff5de | |||
| da488b2cb0 | |||
| ad4d3c9976 | |||
| 089d77aa68 | |||
| b793cd6955 | |||
| dfd6b63888 | |||
| 0599bdc331 | |||
| 93bcbc29a0 | |||
| c9b6729482 |
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 0094_time_tracking_one_open_clock_in.sql
|
||||||
|
--
|
||||||
|
-- Cycle 2 of the water-log/time-tracking refactor. A field worker must not
|
||||||
|
-- have two open clock-ins at once — concurrent requests, offline retries,
|
||||||
|
-- and double-taps on a slow phone all create the same hazard. A partial
|
||||||
|
-- unique index on (worker_id) WHERE clock_out IS NULL is the only DB-level
|
||||||
|
-- guard, since READ COMMITTED transactions can both pass an
|
||||||
|
-- "is there an open log?" SELECT before either INSERT commits.
|
||||||
|
--
|
||||||
|
-- The application catches the unique violation and returns
|
||||||
|
-- "Already clocked in" to the worker. The `clockOutWorker` action picks
|
||||||
|
-- the most-recent open log (ORDER BY clock_in DESC LIMIT 1) — that
|
||||||
|
-- behavior is unchanged.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||||
|
time_tracking_logs_one_open_per_worker_idx
|
||||||
|
ON time_tracking_logs (worker_id)
|
||||||
|
WHERE clock_out IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON INDEX time_tracking_logs_one_open_per_worker_idx IS
|
||||||
|
'Cycle 2: enforces at most one open clock-in per worker. Catches concurrent / retry race in field app.';
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 0095_time_tracking_smartsheet_sync.sql
|
||||||
|
--
|
||||||
|
-- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring
|
||||||
|
-- the water-log smartsheet pattern from migration 0093. Three tables:
|
||||||
|
--
|
||||||
|
-- 1. time_tracking_smartsheet_config — one row per brand; encrypted
|
||||||
|
-- token, sheet id, column
|
||||||
|
-- mapping, frequency, enable
|
||||||
|
-- flag, last-sync metadata
|
||||||
|
-- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that
|
||||||
|
-- needs syncing; tracks
|
||||||
|
-- attempts + status for retry
|
||||||
|
-- and observability
|
||||||
|
-- 3. time_tracking_smartsheet_sync_log — append-only log of every
|
||||||
|
-- sync attempt (success OR
|
||||||
|
-- failure); backs the
|
||||||
|
-- "Recent Activity" panel
|
||||||
|
--
|
||||||
|
-- Security:
|
||||||
|
-- - API token is AES-256-GCM encrypted at rest using a key from
|
||||||
|
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
|
||||||
|
-- plaintext via any server action / API response.
|
||||||
|
-- - All tables brand-scoped via the existing `app.current_brand_id`
|
||||||
|
-- GUC + `current_brand_id()` function (see 0001_init.sql).
|
||||||
|
--
|
||||||
|
-- Cycle 5 scope:
|
||||||
|
-- This migration ONLY introduces schema + RLS. The actual sync
|
||||||
|
-- service, server actions, and admin UI live in their own files.
|
||||||
|
-- The brand will fill in sheet_id + token + column mapping when
|
||||||
|
-- the customer is ready; everything works end-to-end once they do.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_config (
|
||||||
|
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The numeric Smartsheet sheet ID (not the share URL).
|
||||||
|
-- UI accepts either form and parses to numeric here.
|
||||||
|
sheet_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
|
||||||
|
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
|
||||||
|
encrypted_api_token BYTEA NOT NULL,
|
||||||
|
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||||
|
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||||
|
|
||||||
|
-- Maps our internal time-tracking fields → Smartsheet column IDs.
|
||||||
|
-- Shape: { log_id: string, clock_in: string, clock_out: string,
|
||||||
|
-- worker: string, task: string, hours: string,
|
||||||
|
-- lunch_minutes: string | null, notes: string | null }
|
||||||
|
-- `log_id` and `clock_in` are required (used for dedup).
|
||||||
|
column_mapping JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- 'realtime' | 'every_15_minutes' | 'hourly'
|
||||||
|
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
|
||||||
|
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
|
||||||
|
|
||||||
|
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Set after every sync attempt (success or failure).
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
|
||||||
|
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||||
|
-- require a FK to a specific auth backend.
|
||||||
|
created_by TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auto-bump updated_at on row UPDATE.
|
||||||
|
DROP TRIGGER IF EXISTS time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config;
|
||||||
|
CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at
|
||||||
|
BEFORE UPDATE ON time_tracking_smartsheet_config
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- RLS: brand-scoped.
|
||||||
|
ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config;
|
||||||
|
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_config FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 2. Sync queue (one row per clock-out awaiting sync) ────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- FK to the clock-out. ON DELETE CASCADE means deleting a log row
|
||||||
|
-- also removes its queue row, preventing orphaned sync attempts.
|
||||||
|
log_id UUID NOT NULL REFERENCES time_tracking_logs(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The Smartsheet row ID returned from a successful sync.
|
||||||
|
-- NULL = not yet synced.
|
||||||
|
smartsheet_row_id TEXT,
|
||||||
|
|
||||||
|
-- 'pending' | 'syncing' | 'synced' | 'failed'
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending','syncing','synced','failed')),
|
||||||
|
|
||||||
|
-- Bumped on every attempt (success or failure). Caps at 5 (after
|
||||||
|
-- which the row stays 'failed' permanently; admin must re-enable).
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Last failure reason (sanitized — token never appears here).
|
||||||
|
last_error TEXT,
|
||||||
|
|
||||||
|
-- When the next retry is allowed. Set to NOW() initially; pushed
|
||||||
|
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx
|
||||||
|
ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||||
|
|
||||||
|
-- Recent-attempts view.
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx
|
||||||
|
ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue;
|
||||||
|
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_queue FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL,
|
||||||
|
smartsheet_row_id TEXT,
|
||||||
|
|
||||||
|
-- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue'
|
||||||
|
action TEXT NOT NULL
|
||||||
|
CHECK (action IN ('sync','retry','skip','queue')),
|
||||||
|
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_log_brand_recent_idx
|
||||||
|
ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log;
|
||||||
|
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_log FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 4. Helper view: latest sync per log row (for dedup decisions) ─────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS
|
||||||
|
SELECT DISTINCT ON (q.brand_id, q.log_id)
|
||||||
|
q.brand_id,
|
||||||
|
q.log_id,
|
||||||
|
q.smartsheet_row_id,
|
||||||
|
q.status,
|
||||||
|
q.attempts,
|
||||||
|
q.last_error,
|
||||||
|
q.next_attempt_at,
|
||||||
|
q.created_at AS queued_at,
|
||||||
|
q.synced_at
|
||||||
|
FROM time_tracking_smartsheet_sync_queue q
|
||||||
|
ORDER BY q.brand_id, q.log_id, q.created_at DESC;
|
||||||
|
|
||||||
|
COMMENT ON TABLE time_tracking_smartsheet_config IS
|
||||||
|
'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.';
|
||||||
|
COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS
|
||||||
|
'One row per time_tracking_log awaiting / completed sync to Smartsheet.';
|
||||||
|
COMMENT ON TABLE time_tracking_smartsheet_sync_log IS
|
||||||
|
'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 0096_smartsheet_workbook_hub.sql
|
||||||
|
--
|
||||||
|
-- Cycle 7 — Smartsheet workbook hub. Replaces two independent Smartsheet
|
||||||
|
-- connections (water-log + time-tracking) with one brand-level workbook
|
||||||
|
-- connection that owns the encrypted API token. Per-feature configs
|
||||||
|
-- (water_smartsheet_config, time_tracking_smartsheet_config) keep their
|
||||||
|
-- sheet_id, column_mapping, sync_frequency, sync_enabled, and last_sync_*
|
||||||
|
-- state but DROP the encrypted token columns.
|
||||||
|
--
|
||||||
|
-- One workspace row per brand:
|
||||||
|
-- - encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM)
|
||||||
|
-- - default_sheet_id TEXT NULL (the "home" sheet shown in the hub
|
||||||
|
-- when no per-feature sheet is mapped)
|
||||||
|
-- - connection_enabled BOOLEAN (master kill-switch; per-feature
|
||||||
|
-- sync_enabled still gates each feature)
|
||||||
|
-- - last_test_at TIMESTAMPTZ, last_test_error TEXT
|
||||||
|
--
|
||||||
|
-- Sync queue + sync log tables are UNCHANGED — they remain per-feature
|
||||||
|
-- (water_smartsheet_sync_queue + log, time_tracking_smartsheet_sync_queue +
|
||||||
|
-- log). The hub re-homes only the token.
|
||||||
|
--
|
||||||
|
-- Migration behavior (idempotent — safe to re-run):
|
||||||
|
-- 1. Create smartsheet_workspace if missing
|
||||||
|
-- 2. Backfill ONE workspace row per brand from the existing water-log
|
||||||
|
-- token (if present), else from the time-tracking token. Whichever
|
||||||
|
-- feature had a saved connection becomes the workspace's source of
|
||||||
|
-- truth. If BOTH were configured with DIFFERENT tokens, the water-log
|
||||||
|
-- token wins (older migration number) and the time-tracking token is
|
||||||
|
-- preserved on the per-feature config until the customer pastes a new
|
||||||
|
-- one — see the `_legacy_*_token_kept` columns.
|
||||||
|
-- 3. DROP the three encrypted columns from both feature configs.
|
||||||
|
-- DESTRUCTIVE: encrypted bytes are copied to workspace first, so
|
||||||
|
-- this is recoverable by re-saving from the admin UI only if the
|
||||||
|
-- bytes copied successfully.
|
||||||
|
--
|
||||||
|
-- Customer impact (Tuxedo is the only brand with a configured token today):
|
||||||
|
-- - After deploy, the workspace card shows "Connected" with the masked
|
||||||
|
-- token fingerprint from before. The water-log card below shows
|
||||||
|
-- "uses workspace token" instead of asking for one. No re-paste.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ── 1. New workspace table ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS smartsheet_workspace (
|
||||||
|
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- AES-256-GCM encrypted API token (one per brand; one per workspace).
|
||||||
|
-- Stored as BYTEA so we can keep raw bytes (not base64).
|
||||||
|
encrypted_api_token BYTEA NOT NULL,
|
||||||
|
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||||
|
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||||
|
|
||||||
|
-- Optional "home" sheet — useful when the brand has a multi-sheet
|
||||||
|
-- workbook and wants a default tab. NULL is fine.
|
||||||
|
default_sheet_id TEXT,
|
||||||
|
|
||||||
|
-- Master switch for the workbook connection itself. Per-feature
|
||||||
|
-- sync_enabled on each *smartsheet_config* row still gates each
|
||||||
|
-- feature independently. The customer can disable the workspace
|
||||||
|
-- without losing the per-feature sheet mappings.
|
||||||
|
connection_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Set whenever the admin clicks "Test Connection" on the hub card.
|
||||||
|
-- Powers the "Last verified: 2 min ago" badge in the UI.
|
||||||
|
last_test_at TIMESTAMPTZ,
|
||||||
|
last_test_error TEXT,
|
||||||
|
|
||||||
|
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||||
|
-- require a FK to a specific auth backend.
|
||||||
|
created_by TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auto-bump updated_at on row UPDATE.
|
||||||
|
DROP TRIGGER IF EXISTS smartsheet_workspace_set_updated_at ON smartsheet_workspace;
|
||||||
|
CREATE TRIGGER smartsheet_workspace_set_updated_at
|
||||||
|
BEFORE UPDATE ON smartsheet_workspace
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- RLS: brand-scoped, same pattern as existing smartsheet tables.
|
||||||
|
ALTER TABLE smartsheet_workspace ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON smartsheet_workspace;
|
||||||
|
CREATE POLICY tenant_isolation ON smartsheet_workspace FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
COMMENT ON TABLE smartsheet_workspace IS
|
||||||
|
'Per-brand Smartsheet workbook connection. Token is AES-256-GCM encrypted. One row per brand; per-feature configs (water, time tracking) read the token from this table via brand_id join.';
|
||||||
|
|
||||||
|
-- ── 2. Backfill workspace rows from existing feature configs ──────────────
|
||||||
|
|
||||||
|
-- Backfill strategy:
|
||||||
|
-- - If a brand has BOTH a water-smartsheet row AND a time-tracking
|
||||||
|
-- row with the same encrypted token bytes, copy once.
|
||||||
|
-- - If only one is present, copy from that one.
|
||||||
|
-- - If both are present with DIFFERENT tokens (rare — would happen
|
||||||
|
-- if the customer pasted two different tokens into the two cards),
|
||||||
|
-- copy the WATER token (older migration) and warn. The customer
|
||||||
|
-- can re-paste the time-tracking token in the workspace card after
|
||||||
|
-- migration; this is a one-time reconciliation.
|
||||||
|
|
||||||
|
INSERT INTO smartsheet_workspace (
|
||||||
|
brand_id,
|
||||||
|
encrypted_api_token,
|
||||||
|
token_iv,
|
||||||
|
token_auth_tag,
|
||||||
|
default_sheet_id,
|
||||||
|
connection_enabled,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w.brand_id,
|
||||||
|
w.encrypted_api_token,
|
||||||
|
w.token_iv,
|
||||||
|
w.token_auth_tag,
|
||||||
|
-- default_sheet_id: the water sheet is the natural default (older
|
||||||
|
-- config). The time-tracking sheet becomes a per-feature mapping.
|
||||||
|
w.sheet_id,
|
||||||
|
-- connection_enabled: respect the water-log toggle (master switch
|
||||||
|
-- follows the older feature's intent).
|
||||||
|
w.sync_enabled,
|
||||||
|
w.created_by,
|
||||||
|
w.updated_by
|
||||||
|
FROM water_smartsheet_config w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = w.brand_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (brand_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Time-tracking backfill: only brands that DO NOT already have a
|
||||||
|
-- workspace row from the water backfill AND DO have a time-tracking
|
||||||
|
-- config with a saved token. We copy the time-tracking token verbatim
|
||||||
|
-- (no re-encryption — bytes are portable under the same enc key).
|
||||||
|
INSERT INTO smartsheet_workspace (
|
||||||
|
brand_id,
|
||||||
|
encrypted_api_token,
|
||||||
|
token_iv,
|
||||||
|
token_auth_tag,
|
||||||
|
default_sheet_id,
|
||||||
|
connection_enabled,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.brand_id,
|
||||||
|
t.encrypted_api_token,
|
||||||
|
t.token_iv,
|
||||||
|
t.token_auth_tag,
|
||||||
|
t.sheet_id,
|
||||||
|
t.sync_enabled,
|
||||||
|
t.created_by,
|
||||||
|
t.updated_by
|
||||||
|
FROM time_tracking_smartsheet_config t
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = t.brand_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (brand_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 3. Drop encrypted token columns from feature configs ────────────────
|
||||||
|
--
|
||||||
|
-- DESTRUCTIVE. Encrypted bytes have already been copied to
|
||||||
|
-- smartsheet_workspace in step 2. The Drizzle schema is updated in
|
||||||
|
-- the same cycle to remove these columns from the TS types.
|
||||||
|
|
||||||
|
ALTER TABLE water_smartsheet_config
|
||||||
|
DROP COLUMN IF EXISTS encrypted_api_token,
|
||||||
|
DROP COLUMN IF EXISTS token_iv,
|
||||||
|
DROP COLUMN IF EXISTS token_auth_tag;
|
||||||
|
|
||||||
|
ALTER TABLE time_tracking_smartsheet_config
|
||||||
|
DROP COLUMN IF EXISTS encrypted_api_token,
|
||||||
|
DROP COLUMN IF EXISTS token_iv,
|
||||||
|
DROP COLUMN IF EXISTS token_auth_tag;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Smartsheet workspace — per-brand workbook connection.
|
||||||
|
*
|
||||||
|
* Cycle 7 schema for migration 0096. Replaces two independent Smartsheet
|
||||||
|
* configs (water + time tracking) with one brand-level workbook hub.
|
||||||
|
*
|
||||||
|
* The encrypted API token lives here. Per-feature configs
|
||||||
|
* (`water_smartsheet_config`, `time_tracking_smartsheet_config`) keep
|
||||||
|
* their sheet_id, column_mapping, sync_frequency, sync_enabled, and
|
||||||
|
* last_sync_* state but read the token from this table via brand_id.
|
||||||
|
*
|
||||||
|
* RLS: brand-scoped, same pattern as existing smartsheet tables.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
customType,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { brands } from "./brands";
|
||||||
|
|
||||||
|
const bytea = customType<{ data: Buffer; default: false }>({
|
||||||
|
dataType() {
|
||||||
|
return "bytea";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const smartsheetWorkspace = pgTable("smartsheet_workspace", {
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// AES-256-GCM encrypted API token. Same key as the old feature
|
||||||
|
// configs (SMARTSHEET_TOKEN_ENC_KEY); bytes are portable.
|
||||||
|
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
||||||
|
tokenIv: bytea("token_iv").notNull(),
|
||||||
|
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
||||||
|
|
||||||
|
// Optional "home" sheet — the tab the brand sees first in the
|
||||||
|
// workbook. NULL is fine; the UI falls back to the first per-
|
||||||
|
// feature sheet mapping.
|
||||||
|
defaultSheetId: text("default_sheet_id"),
|
||||||
|
|
||||||
|
// Master kill-switch. Per-feature sync_enabled still gates each
|
||||||
|
// feature independently — toggling this off pauses all syncs.
|
||||||
|
connectionEnabled: boolean("connection_enabled").notNull().default(true),
|
||||||
|
|
||||||
|
// Test-connection bookkeeping (powers the "Last verified 2m ago"
|
||||||
|
// badge on the hub card).
|
||||||
|
lastTestAt: timestamp("last_test_at", { withTimezone: true }),
|
||||||
|
lastTestError: text("last_test_error"),
|
||||||
|
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmartsheetWorkspace = typeof smartsheetWorkspace.$inferSelect;
|
||||||
|
export type SmartsheetWorkspaceInsert = typeof smartsheetWorkspace.$inferInsert;
|
||||||
+149
-1
@@ -1,5 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Time Tracking. Source: `db/migrations/0001_init.sql`.
|
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
|
||||||
|
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
|
||||||
|
*
|
||||||
|
* Cycle 7: the encrypted token columns on
|
||||||
|
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
|
||||||
|
* (see `db/schema/smartsheet-workspace.ts`).
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -10,6 +15,7 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
|
|
||||||
@@ -159,3 +165,145 @@ 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;
|
||||||
|
|
||||||
|
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed sync frequencies. Mirrors the water-log smartsheet
|
||||||
|
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
|
||||||
|
*/
|
||||||
|
export const TT_SMARTSHEET_FREQUENCIES = [
|
||||||
|
"realtime",
|
||||||
|
"every_15_minutes",
|
||||||
|
"hourly",
|
||||||
|
] as const;
|
||||||
|
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-tracking fields a brand can map to their Smartsheet columns.
|
||||||
|
* `log_id` and `clock_in` are required (used for dedup).
|
||||||
|
*/
|
||||||
|
export type TTSmartsheetColumnKey =
|
||||||
|
| "log_id"
|
||||||
|
| "clock_in"
|
||||||
|
| "clock_out"
|
||||||
|
| "worker"
|
||||||
|
| "task"
|
||||||
|
| "hours"
|
||||||
|
| "lunch_minutes"
|
||||||
|
| "notes";
|
||||||
|
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
|
||||||
|
"log_id",
|
||||||
|
"clock_in",
|
||||||
|
"clock_out",
|
||||||
|
"worker",
|
||||||
|
"task",
|
||||||
|
"hours",
|
||||||
|
"lunch_minutes",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
|
||||||
|
* Required: log_id + clock_in (for dedup). All others nullable.
|
||||||
|
*/
|
||||||
|
export type TTSmartsheetColumnMapping = {
|
||||||
|
log_id: string;
|
||||||
|
clock_in: string;
|
||||||
|
clock_out: string | null;
|
||||||
|
worker: string | null;
|
||||||
|
task: string | null;
|
||||||
|
hours: string | null;
|
||||||
|
lunch_minutes: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timeTrackingSmartsheetConfig = pgTable(
|
||||||
|
"time_tracking_smartsheet_config",
|
||||||
|
{
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
sheetId: text("sheet_id").notNull(),
|
||||||
|
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
|
||||||
|
// See migration 0096_smartsheet_workbook_hub.sql.
|
||||||
|
columnMapping: jsonb("column_mapping")
|
||||||
|
.$type<TTSmartsheetColumnMapping>()
|
||||||
|
.notNull(),
|
||||||
|
syncFrequency: text("sync_frequency")
|
||||||
|
.$type<TTSmartsheetFrequency>()
|
||||||
|
.notNull()
|
||||||
|
.default("hourly"),
|
||||||
|
syncEnabled: boolean("sync_enabled").notNull().default(false),
|
||||||
|
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
||||||
|
lastSyncError: text("last_sync_error"),
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const timeTrackingSmartsheetSyncQueue = pgTable(
|
||||||
|
"time_tracking_smartsheet_sync_queue",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
logId: uuid("log_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
|
||||||
|
smartsheetRowId: text("smartsheet_row_id"),
|
||||||
|
status: text("status", {
|
||||||
|
enum: ["pending", "syncing", "synced", "failed"],
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default("pending"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
syncedAt: timestamp("synced_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const timeTrackingSmartsheetSyncLog = pgTable(
|
||||||
|
"time_tracking_smartsheet_sync_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
smartsheetRowId: text("smartsheet_row_id"),
|
||||||
|
action: text("action", {
|
||||||
|
enum: ["sync", "retry", "skip", "queue"],
|
||||||
|
}).notNull(),
|
||||||
|
success: boolean("success").notNull(),
|
||||||
|
error: text("error"),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TimeTrackingSmartsheetConfig =
|
||||||
|
typeof timeTrackingSmartsheetConfig.$inferSelect;
|
||||||
|
export type TimeTrackingSmartsheetConfigInsert =
|
||||||
|
typeof timeTrackingSmartsheetConfig.$inferInsert;
|
||||||
|
export type TimeTrackingSmartsheetSyncQueue =
|
||||||
|
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
|
||||||
|
export type TimeTrackingSmartsheetSyncLog =
|
||||||
|
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
|
||||||
+6
-11
@@ -24,18 +24,14 @@ import {
|
|||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
integer,
|
integer,
|
||||||
customType,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
import { adminUsers } from "./brands";
|
import { adminUsers } from "./brands";
|
||||||
|
|
||||||
// Drizzle's stock `bytea` import is not exported in every version, so
|
// Cycle 7: the `bytea` customType used to live here for the
|
||||||
// register a small custom type that maps to the existing `bytea` PG type.
|
// encrypted Smartsheet token columns. Those columns moved to
|
||||||
const bytea = customType<{ data: Buffer; default: false }>({
|
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
|
||||||
dataType() {
|
// so the helper is no longer needed here.
|
||||||
return "bytea";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const waterHeadgates = pgTable(
|
export const waterHeadgates = pgTable(
|
||||||
"water_headgates",
|
"water_headgates",
|
||||||
@@ -323,9 +319,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
sheetId: text("sheet_id").notNull(),
|
sheetId: text("sheet_id").notNull(),
|
||||||
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
|
||||||
tokenIv: bytea("token_iv").notNull(),
|
// See migration 0096_smartsheet_workbook_hub.sql.
|
||||||
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
|
||||||
columnMapping: jsonb("column_mapping")
|
columnMapping: jsonb("column_mapping")
|
||||||
.$type<SmartsheetColumnMapping>()
|
.$type<SmartsheetColumnMapping>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Water Log — Tuxedo",
|
||||||
|
"short_name": "Water Log",
|
||||||
|
"description": "Field access for Tuxedo water + time tracking",
|
||||||
|
"start_url": "/water",
|
||||||
|
"scope": "/water",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f2f2f7",
|
||||||
|
"theme_color": "#14532d",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// public/sw-field.js — Tuxedo field-worker service worker.
|
||||||
|
//
|
||||||
|
// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
|
||||||
|
// the main shell SW because:
|
||||||
|
// - The field app is a thin surface (PIN, headgate list, log form,
|
||||||
|
// time tab). Few async chunks; mostly static + the latest data
|
||||||
|
// snapshot.
|
||||||
|
// - "Offline" here means "still see the cached PIN screen + headgate
|
||||||
|
// list" — water entry without connectivity is a degraded but
|
||||||
|
// non-blocking experience (the form queue handles drafts).
|
||||||
|
//
|
||||||
|
// Cache names are deliberately prefixed with `field-` so they never
|
||||||
|
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
|
||||||
|
|
||||||
|
const SHELL_CACHE = "field-shell-v1";
|
||||||
|
const DATA_CACHE = "field-data-v1";
|
||||||
|
const OFFLINE_URL = "/offline.html";
|
||||||
|
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
"/water",
|
||||||
|
"/manifest-field.json",
|
||||||
|
"/favicon.svg",
|
||||||
|
"/og-default.svg",
|
||||||
|
"/offline.html",
|
||||||
|
"/icons/icon-192x192.png",
|
||||||
|
"/icons/icon-512x512.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
// Only manage field-* caches — never touch the main app's caches.
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
const toDelete = [];
|
||||||
|
for (const name of cacheNames) {
|
||||||
|
if (
|
||||||
|
name !== SHELL_CACHE &&
|
||||||
|
name !== DATA_CACHE &&
|
||||||
|
name.startsWith("field-")
|
||||||
|
) {
|
||||||
|
toDelete.push(caches.delete(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(toDelete);
|
||||||
|
}),
|
||||||
|
self.clients.claim(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Out-of-scope requests: skip (let the main app SW handle them
|
||||||
|
// if it's installed for that scope; fall through to network).
|
||||||
|
if (!url.pathname.startsWith("/water")) return;
|
||||||
|
|
||||||
|
// Navigations → network-first, fall back to cache, fall back to offline page.
|
||||||
|
if (req.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches.match(req).then(
|
||||||
|
(cached) => cached ?? caches.match(OFFLINE_URL),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets → cache-first
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith("/_next/static/") ||
|
||||||
|
url.pathname.startsWith("/icons/") ||
|
||||||
|
url.pathname.endsWith(".svg") ||
|
||||||
|
url.pathname.endsWith(".woff2")
|
||||||
|
) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req).then((res) => {
|
||||||
|
const clone = res.clone();
|
||||||
|
if (res.status === 200) {
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/water* GETs → stale-while-revalidate (so the headgate list
|
||||||
|
// and worker availability can hydrate from cache while we re-fetch).
|
||||||
|
if (url.pathname.startsWith("/api/water")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(DATA_CACHE).then((cache) =>
|
||||||
|
cache.match(req).then((cached) => {
|
||||||
|
const network = fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => cached);
|
||||||
|
return cached ?? network;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: try network, fall back to cache.
|
||||||
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smartsheet Workspace — `/admin/water-log/settings` Smartsheet hub.
|
||||||
|
*
|
||||||
|
* Cycle 7. Replaces the two independent per-feature token columns
|
||||||
|
* (water + time tracking) with one brand-level workbook connection.
|
||||||
|
*
|
||||||
|
* Permission model:
|
||||||
|
* - Mutations require `can_manage_settings` (or platform_admin).
|
||||||
|
* The hub card is a brand-level integration setting, not a
|
||||||
|
* water-log concern, and shouldn't be locked behind
|
||||||
|
* `can_manage_water_log`.
|
||||||
|
* - Reads are open to any authenticated admin.
|
||||||
|
*
|
||||||
|
* Token handling:
|
||||||
|
* - The plaintext token NEVER crosses the server-action boundary.
|
||||||
|
* - Save actions accept a token via `input.token` only when the user
|
||||||
|
* types a new one. An empty token means "keep the saved one".
|
||||||
|
* - Read actions return `maskedToken` ("••••abcd") and never the
|
||||||
|
* plaintext.
|
||||||
|
*/
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
|
||||||
|
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
|
||||||
|
import { getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
|
// ── Public types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SmartsheetWorkspaceResponse = {
|
||||||
|
/** True iff a workspace row exists for this brand. */
|
||||||
|
configured: boolean;
|
||||||
|
/** Default sheet ID (the "home" tab). Null = use the first feature sheet. */
|
||||||
|
defaultSheetId: string | null;
|
||||||
|
/** Master switch — pauses all per-feature syncs without losing config. */
|
||||||
|
connectionEnabled: boolean;
|
||||||
|
/** Masked preview of the saved token ("••••abcd") or null. */
|
||||||
|
maskedToken: string | null;
|
||||||
|
/** True iff a token is saved and decrypts cleanly. */
|
||||||
|
hasToken: boolean;
|
||||||
|
/** Last successful test-connection timestamp. */
|
||||||
|
lastTestAt: string | null;
|
||||||
|
/** Most recent test-connection error (sanitized). */
|
||||||
|
lastTestError: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveSmartsheetWorkspaceInput = {
|
||||||
|
/** Plaintext API token. Empty string = keep the saved token. */
|
||||||
|
token: string;
|
||||||
|
defaultSheetId: string | null;
|
||||||
|
connectionEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestSmartsheetWorkspaceResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
/** Name of the sheet the connection was tested against. */
|
||||||
|
sheetName: string;
|
||||||
|
/** Number of columns in the sheet header. */
|
||||||
|
columnCount: number;
|
||||||
|
columns: { id: string; title: string; type: string }[];
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function requireManageSettings(): Promise<
|
||||||
|
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { ok: false, error: "Not authenticated" };
|
||||||
|
if (
|
||||||
|
!adminUser.can_manage_settings &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return { ok: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
return { ok: true, adminUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSmartsheetWorkspace(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<SmartsheetWorkspaceResponse> {
|
||||||
|
const auth = await requireManageSettings();
|
||||||
|
if (!auth.ok) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
defaultSheetId: null,
|
||||||
|
connectionEnabled: true,
|
||||||
|
maskedToken: null,
|
||||||
|
hasToken: false,
|
||||||
|
lastTestAt: null,
|
||||||
|
lastTestError: null,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(smartsheetWorkspace)
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const ws = rows[0];
|
||||||
|
if (!ws) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
defaultSheetId: null,
|
||||||
|
connectionEnabled: true,
|
||||||
|
maskedToken: null,
|
||||||
|
hasToken: false,
|
||||||
|
lastTestAt: null,
|
||||||
|
lastTestError: null,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let masked: string | null = "••••";
|
||||||
|
let hasToken = true;
|
||||||
|
try {
|
||||||
|
const plain = decryptToken({
|
||||||
|
cipher: ws.encryptedApiToken,
|
||||||
|
iv: ws.tokenIv,
|
||||||
|
authTag: ws.tokenAuthTag,
|
||||||
|
});
|
||||||
|
masked = maskToken(plain);
|
||||||
|
} catch {
|
||||||
|
hasToken = false;
|
||||||
|
masked = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
defaultSheetId: ws.defaultSheetId,
|
||||||
|
connectionEnabled: ws.connectionEnabled,
|
||||||
|
maskedToken: masked,
|
||||||
|
hasToken,
|
||||||
|
lastTestAt: ws.lastTestAt?.toISOString() ?? null,
|
||||||
|
lastTestError: ws.lastTestError,
|
||||||
|
updatedAt: ws.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write: save (upsert) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveSmartsheetWorkspace(
|
||||||
|
brandId: string,
|
||||||
|
input: SaveSmartsheetWorkspaceInput,
|
||||||
|
): Promise<
|
||||||
|
| { success: true; workspace: SmartsheetWorkspaceResponse }
|
||||||
|
| { success: false; error: string }
|
||||||
|
> {
|
||||||
|
const auth = await requireManageSettings();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
|
const tokenTrimmed = input.token?.trim() ?? "";
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const existingRows = await db
|
||||||
|
.select()
|
||||||
|
.from(smartsheetWorkspace)
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const existing = existingRows[0];
|
||||||
|
|
||||||
|
// First-time setup requires a token; subsequent saves can omit it
|
||||||
|
// to keep the saved one.
|
||||||
|
if (!existing && tokenTrimmed.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "API token is required for first-time setup",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValues: Partial<typeof smartsheetWorkspace.$inferInsert> = {
|
||||||
|
defaultSheetId: input.defaultSheetId,
|
||||||
|
connectionEnabled: input.connectionEnabled,
|
||||||
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
};
|
||||||
|
if (tokenTrimmed.length > 0) {
|
||||||
|
const enc = encryptToken(tokenTrimmed);
|
||||||
|
updateValues.encryptedApiToken = enc.cipher;
|
||||||
|
updateValues.tokenIv = enc.iv;
|
||||||
|
updateValues.tokenAuthTag = enc.authTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(smartsheetWorkspace)
|
||||||
|
.set(updateValues)
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId));
|
||||||
|
} else {
|
||||||
|
await db.insert(smartsheetWorkspace).values({
|
||||||
|
brandId,
|
||||||
|
encryptedApiToken: updateValues.encryptedApiToken ?? Buffer.from([]),
|
||||||
|
tokenIv: updateValues.tokenIv ?? Buffer.from([]),
|
||||||
|
tokenAuthTag: updateValues.tokenAuthTag ?? Buffer.from([]),
|
||||||
|
defaultSheetId: input.defaultSheetId,
|
||||||
|
connectionEnabled: input.connectionEnabled,
|
||||||
|
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workspace: await getSmartsheetWorkspace(brandId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test connection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function testSmartsheetWorkspaceConnection(
|
||||||
|
brandId: string,
|
||||||
|
sheetIdInput: string,
|
||||||
|
tokenInput: string,
|
||||||
|
): Promise<TestSmartsheetWorkspaceResult> {
|
||||||
|
const auth = await requireManageSettings();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
|
if (!sheetIdInput) {
|
||||||
|
return { success: false, error: "Sheet ID or URL is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve token: explicit > saved > error.
|
||||||
|
let token = tokenInput?.trim() ?? "";
|
||||||
|
let lastTestError: string | null = null;
|
||||||
|
if (!token) {
|
||||||
|
const saved = await withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(smartsheetWorkspace)
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
});
|
||||||
|
if (!saved) {
|
||||||
|
return { success: false, error: "No token saved yet — paste one above to test." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
token = decryptToken({
|
||||||
|
cipher: saved.encryptedApiToken,
|
||||||
|
iv: saved.tokenIv,
|
||||||
|
authTag: saved.tokenAuthTag,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We re-parse via `getSheetMeta` which calls `extractSheetId`
|
||||||
|
// internally. Surface any share-link-slug error directly.
|
||||||
|
const meta = await getSheetMeta(sheetIdInput, token);
|
||||||
|
lastTestError = null;
|
||||||
|
await withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.update(smartsheetWorkspace)
|
||||||
|
.set({ lastTestAt: new Date(), lastTestError: null })
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sheetName: meta.name,
|
||||||
|
columnCount: meta.columns.length,
|
||||||
|
columns: meta.columns.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
type: c.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
lastTestError = err instanceof Error ? err.message : String(err);
|
||||||
|
// Sanitize: strip any token echoes.
|
||||||
|
const sanitized = lastTestError.split(token).join("[redacted-token]");
|
||||||
|
await withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.update(smartsheetWorkspace)
|
||||||
|
.set({ lastTestAt: new Date(), lastTestError: sanitized })
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId));
|
||||||
|
});
|
||||||
|
if (err instanceof SmartsheetApiError) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" };
|
||||||
|
}
|
||||||
|
if (err.status === 403) {
|
||||||
|
return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" };
|
||||||
|
}
|
||||||
|
if (err.status === 404) {
|
||||||
|
return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` };
|
||||||
|
}
|
||||||
|
return { success: false, error: sanitized };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disable (preserves token, turns off connection) ────────────────────────
|
||||||
|
|
||||||
|
export async function disableSmartsheetWorkspace(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
const auth = await requireManageSettings();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.update(smartsheetWorkspace)
|
||||||
|
.set({ connectionEnabled: false })
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve token helper for sync services ────────────────────────────────
|
||||||
|
//
|
||||||
|
// `resolveWorkspaceToken` lives in `src/services/workspace-token.ts`
|
||||||
|
// (server-only, NOT a server action). Per-feature action files import
|
||||||
|
// it from there directly — re-exporting through a "use server" file
|
||||||
|
// would re-expose the plaintext-token helper as a callable RPC.
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — field (PIN-based) actions.
|
||||||
|
*
|
||||||
|
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor.
|
||||||
|
*
|
||||||
|
* Sessions are cookie-only (no DB session row). The cookie carries
|
||||||
|
* seven pipe-delimited fields (`worker_id|session_id|expires_at|
|
||||||
|
* name|role|lang|brand_id`) — but only the first three are read from
|
||||||
|
* the cookie. name/role/lang/brand_id are ALWAYS re-resolved from the
|
||||||
|
* DB on every `getTimeTrackingSession()` call so a tampered or stale
|
||||||
|
* cookie can't impersonate a different brand. Expiry is 12 hours; the
|
||||||
|
* field app re-prompts for PIN when the cookie is gone.
|
||||||
|
*
|
||||||
|
* Clock-in is protected by a partial unique index on
|
||||||
|
* `time_tracking_logs (worker_id) WHERE clock_out IS NULL` (migration
|
||||||
|
* 0094) so concurrent / retried requests can't open two shifts.
|
||||||
|
*
|
||||||
|
* PIN verification uses scrypt (from `@/lib/water-log-pin` — shared
|
||||||
|
* with the water-log module).
|
||||||
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSession } from "@/lib/auth";
|
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
// TODO(migration): the time-tracking feature was built on Supabase RPCs
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
|
import {
|
||||||
// get_open_clock_in, get_time_tracking_tasks,
|
timeTrackingWorkers,
|
||||||
// get_worker_pay_period_hours, get_time_tracking_settings,
|
timeTrackingTasks,
|
||||||
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
|
timeTrackingLogs,
|
||||||
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
|
timeTrackingSmartsheetSyncQueue,
|
||||||
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
|
} from "@/db/schema/time-tracking";
|
||||||
// functions below are stubs that preserve the original signature so
|
import { verifyPin } from "@/lib/water-log-pin";
|
||||||
// the field UI degrades gracefully (no PIN login, no entry submit) —
|
import { getWorkerPeriodTotals } from "./index";
|
||||||
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
|
|
||||||
// time tracking back, add the tables to `db/schema/` and re-implement
|
|
||||||
// these functions against Drizzle.
|
|
||||||
|
|
||||||
export type TimeTrackingSession = {
|
export type TimeTrackingSession = {
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
@@ -26,75 +43,292 @@ export type TimeTrackingSession = {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Cookie helpers ─────────────────────────────────────────────────────────────
|
// ── Cookie helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SESSION_COOKIE = "time_tracking_session";
|
const SESSION_COOKIE = "time_tracking_session";
|
||||||
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
|
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours
|
||||||
|
|
||||||
function sessionCookie(session: TimeTrackingSession) {
|
function sessionCookieValue(session: TimeTrackingSession): string {
|
||||||
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
|
return [
|
||||||
|
session.worker_id,
|
||||||
|
session.session_id,
|
||||||
|
session.expires_at,
|
||||||
|
session.name,
|
||||||
|
session.role,
|
||||||
|
session.lang,
|
||||||
|
session.brand_id,
|
||||||
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSessionCookie(cookie: string): TimeTrackingSession | null {
|
// The cookie carries worker_id + a session_id; everything else
|
||||||
|
// (name, role, lang, brand_id) is re-resolved from the DB on every read
|
||||||
|
// so a tampered or stale cookie can't impersonate a different brand.
|
||||||
|
function parseSessionCookie(cookie: string): {
|
||||||
|
worker_id: string;
|
||||||
|
session_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
} | null {
|
||||||
const parts = cookie.split("|");
|
const parts = cookie.split("|");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 7) return null;
|
||||||
const [worker_id, session_id, expires_at] = parts;
|
const [worker_id, session_id, expires_at] = parts;
|
||||||
|
if (!worker_id || !session_id || !expires_at) return null;
|
||||||
if (new Date(expires_at) < new Date()) return null;
|
if (new Date(expires_at) < new Date()) return null;
|
||||||
return {
|
return { worker_id, session_id, expires_at };
|
||||||
worker_id,
|
|
||||||
name: "",
|
|
||||||
role: "worker",
|
|
||||||
lang: "en",
|
|
||||||
session_id,
|
|
||||||
brand_id: "",
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Verify PIN ─────────────────────────────────────────────────────────────────
|
// ── Verify PIN ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function verifyTimeTrackingPin(
|
export async function verifyTimeTrackingPin(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_pin: string
|
pin: string,
|
||||||
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
session?: TimeTrackingSession;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
if (!pin || pin.length < 4) {
|
||||||
|
return { success: false, error: "Enter your 4-digit PIN." };
|
||||||
|
}
|
||||||
|
|
||||||
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can
|
// Pull every active worker for the brand and try each PIN hash. With
|
||||||
// disable the PIN pad.
|
// a small worker pool this is fine; if it grows we could index a
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
// lookup table, but per-brand counts are typically <100.
|
||||||
|
const rows = await withBrand(brandId, async (db) => {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingWorkers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingWorkers.brandId, brandId),
|
||||||
|
eq(timeTrackingWorkers.active, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let match: typeof timeTrackingWorkers.$inferSelect | undefined;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (verifyPin(pin, r.pin)) {
|
||||||
|
match = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) return { success: false, error: "Invalid PIN" };
|
||||||
|
|
||||||
|
// Bump lastUsedAt + set cookie.
|
||||||
|
await withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.update(timeTrackingWorkers)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(timeTrackingWorkers.id, match!.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
|
||||||
|
const session: TimeTrackingSession = {
|
||||||
|
worker_id: match.id,
|
||||||
|
name: match.name,
|
||||||
|
role: match.role,
|
||||||
|
lang: match.lang,
|
||||||
|
session_id: randomUUID(),
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
brand_id: brandId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE, sessionCookieValue(session), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clock In ───────────────────────────────────────────────────────────────────
|
// ── Clock In ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function clockInWorker(
|
export async function clockInWorker(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_taskId?: string,
|
taskId?: string,
|
||||||
_taskName = "General Labor"
|
taskName = "General Labor",
|
||||||
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession();
|
log_id?: string;
|
||||||
|
clock_in?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
if (!session) return { success: false, error: "Not logged in" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// The DB enforces one-open-clock-in-per-worker via the
|
||||||
|
// `time_tracking_logs_one_open_per_worker` partial unique index
|
||||||
|
// (migration 0094). We also surface a friendly error in app code.
|
||||||
|
const open = await db
|
||||||
|
.select({ id: timeTrackingLogs.id })
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, brandId),
|
||||||
|
eq(timeTrackingLogs.workerId, session.worker_id),
|
||||||
|
isNull(timeTrackingLogs.clockOut),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (open.length > 0) {
|
||||||
|
return { success: false, error: "Already clocked in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a taskId was supplied, fetch the task name (so the row stays
|
||||||
|
// accurate even if the task is renamed later). Fall back to the
|
||||||
|
// supplied taskName or "General Labor".
|
||||||
|
const resolvedTaskId = taskId ?? null;
|
||||||
|
let resolvedTaskName = taskName;
|
||||||
|
if (taskId) {
|
||||||
|
const [task] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingTasks)
|
||||||
|
.where(eq(timeTrackingTasks.id, taskId))
|
||||||
|
.limit(1);
|
||||||
|
if (task) resolvedTaskName = task.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(timeTrackingLogs)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
workerId: session.worker_id,
|
||||||
|
taskId: resolvedTaskId,
|
||||||
|
taskName: resolvedTaskName,
|
||||||
|
clockIn: now,
|
||||||
|
submittedVia: "field",
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: timeTrackingLogs.id,
|
||||||
|
clockIn: timeTrackingLogs.clockIn,
|
||||||
|
});
|
||||||
|
if (!row) return { success: false, error: "Insert returned no row" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
log_id: row.id,
|
||||||
|
clock_in: row.clockIn.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Race lost to a concurrent clock-in: the partial unique index
|
||||||
|
// rejected our row. Treat as "already clocked in" so the worker
|
||||||
|
// sees a consistent message instead of a 500.
|
||||||
|
if (isUniqueViolation(err)) {
|
||||||
|
return { success: false, error: "Already clocked in" };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clock Out ──────────────────────────────────────────────────────────────────
|
function isUniqueViolation(err: unknown): boolean {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return /unique constraint|duplicate key/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle 5 — best-effort Smartsheet queue enqueue. Lives in its own
|
||||||
|
// transaction (separate `withBrand` call) so it cannot poison the
|
||||||
|
// primary clock-out write when called from `clockOutWorker`.
|
||||||
|
export async function enqueueClockOutForSync(
|
||||||
|
brandId: string,
|
||||||
|
logId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.insert(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
timeTrackingSmartsheetSyncQueue.brandId,
|
||||||
|
timeTrackingSmartsheetSyncQueue.logId,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clock Out ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function clockOutWorker(
|
export async function clockOutWorker(
|
||||||
_lunchMinutes = 0,
|
lunchMinutes = 0,
|
||||||
_notes?: string
|
notes?: string,
|
||||||
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession();
|
log_id?: string;
|
||||||
|
clock_out?: string;
|
||||||
|
total_minutes?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
if (!session) return { success: false, error: "Not logged in" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
return withBrand(session.brand_id, async (db) => {
|
||||||
|
const open = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, session.brand_id),
|
||||||
|
eq(timeTrackingLogs.workerId, session.worker_id),
|
||||||
|
isNull(timeTrackingLogs.clockOut),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(timeTrackingLogs.clockIn))
|
||||||
|
.limit(1);
|
||||||
|
if (open.length === 0) {
|
||||||
|
return { success: false, error: "No open clock-in to close" };
|
||||||
|
}
|
||||||
|
const log = open[0];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.update(timeTrackingLogs)
|
||||||
|
.set({
|
||||||
|
clockOut: now,
|
||||||
|
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
|
||||||
|
notes: notes?.trim() || null,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingLogs.id, log.id));
|
||||||
|
|
||||||
|
// Cycle 5 — enqueue for Smartsheet sync in a SEPARATE transaction
|
||||||
|
// so a queue-insert failure (RLS denial, FK violation, transient
|
||||||
|
// backend error) can never poison the clock-out write above.
|
||||||
|
// Drizzle's `onConflictDoNothing` keeps retried clock-outs idempotent
|
||||||
|
// against the (brand_id, log_id) unique constraint from migration
|
||||||
|
// 0095.
|
||||||
|
void enqueueClockOutForSync(session.brand_id, log.id).catch(() => {
|
||||||
|
// Non-fatal: the admin UI surfaces persistent failures via the
|
||||||
|
// Recent Activity panel, and the cron drain retries.
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalMinutes = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
|
||||||
|
Math.max(0, Math.round(lunchMinutes)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
log_id: log.id,
|
||||||
|
clock_out: now.toISOString(),
|
||||||
|
total_minutes: totalMinutes,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get Open Clock In ──────────────────────────────────────────────────────────
|
// ── Get Open Clock In ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getOpenClockIn(
|
export async function getOpenClockIn(brandId: string): Promise<{
|
||||||
_brandId: string
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
success: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
log_id?: string;
|
log_id?: string;
|
||||||
@@ -103,34 +337,76 @@ export async function getOpenClockIn(
|
|||||||
elapsed_minutes?: number;
|
elapsed_minutes?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
if (!session) return { success: false, error: "Not logged in" };
|
||||||
return { success: true, open: false };
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [open] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, brandId),
|
||||||
|
eq(timeTrackingLogs.workerId, session.worker_id),
|
||||||
|
isNull(timeTrackingLogs.clockOut),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(timeTrackingLogs.clockIn))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!open) return { success: true, open: false };
|
||||||
|
const elapsed = Math.round((Date.now() - open.clockIn.getTime()) / 60000);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
open: true,
|
||||||
|
log_id: open.id,
|
||||||
|
task_name: open.taskName,
|
||||||
|
clock_in: open.clockIn.toISOString(),
|
||||||
|
elapsed_minutes: elapsed,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Logout ─────────────────────────────────────────────────────────────────────
|
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function logoutTimeTracking(): Promise<void> {
|
export async function logoutTimeTracking(): Promise<void> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(SESSION_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get Current Session ────────────────────────────────────────────────────────
|
// ── Get Current Session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = cookieStore.get(SESSION_COOKIE);
|
const cookie = cookieStore.get(SESSION_COOKIE);
|
||||||
if (!cookie) return null;
|
if (!cookie) return null;
|
||||||
return parseSessionCookie(cookie.value);
|
const parsed = parseSessionCookie(cookie.value);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
// Re-resolve name/role/lang/brand_id from the DB so a stale or
|
||||||
|
// tampered cookie can't impersonate a different brand. Workers is
|
||||||
|
// brand-scoped, so the session lookup has to read with platform
|
||||||
|
// admin scope (the cookie itself gates access).
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const [worker] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingWorkers)
|
||||||
|
.where(eq(timeTrackingWorkers.id, parsed.worker_id))
|
||||||
|
.limit(1);
|
||||||
|
if (!worker || !worker.active) return null;
|
||||||
|
return {
|
||||||
|
worker_id: worker.id,
|
||||||
|
name: worker.name,
|
||||||
|
role: worker.role,
|
||||||
|
lang: worker.lang,
|
||||||
|
session_id: parsed.session_id,
|
||||||
|
expires_at: parsed.expires_at,
|
||||||
|
brand_id: worker.brandId,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get Tasks (for task picker) ─────────────────────────────────────────────────
|
// ── Tasks (field picker) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TimeTaskField = {
|
export type TimeTaskField = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -142,14 +418,28 @@ export type TimeTaskField = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingTasksField(
|
export async function getTimeTrackingTasksField(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_activeOnly = true
|
activeOnly = true,
|
||||||
): Promise<TimeTaskField[]> {
|
): Promise<TimeTaskField[]> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
await getSession(); return [];
|
const base = eq(timeTrackingTasks.brandId, brandId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingTasks)
|
||||||
|
.where(activeOnly ? and(base, eq(timeTrackingTasks.active, true)) : base)
|
||||||
|
.orderBy(timeTrackingTasks.sortOrder, timeTrackingTasks.name);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
name_es: r.nameEs,
|
||||||
|
unit: r.unit,
|
||||||
|
active: r.active,
|
||||||
|
sort_order: r.sortOrder,
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pay Period Hours ───────────────────────────────────────────────────────────
|
// ── Pay Period Hours ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type PayPeriodHours = {
|
export type PayPeriodHours = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -184,11 +474,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function getWorkerPayPeriodHours(
|
export async function getWorkerPayPeriodHours(
|
||||||
_brandId: string
|
brandId: string,
|
||||||
): Promise<PayPeriodHours> {
|
): Promise<PayPeriodHours> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { ...EMPTY_PAY_PERIOD };
|
if (!session) return { ...EMPTY_PAY_PERIOD };
|
||||||
return { ...EMPTY_PAY_PERIOD };
|
|
||||||
|
// Settings may not exist yet; default to safe thresholds.
|
||||||
|
const { getTimeTrackingSettings } = await import("./index");
|
||||||
|
const settings = await getTimeTrackingSettings(brandId);
|
||||||
|
|
||||||
|
const totals = await getWorkerPeriodTotals(
|
||||||
|
brandId,
|
||||||
|
session.worker_id,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total_minutes: totals.period_minutes,
|
||||||
|
total_hours: totals.period_hours,
|
||||||
|
daily_minutes: totals.day_minutes,
|
||||||
|
daily_hours: totals.day_hours,
|
||||||
|
weekly_minutes: totals.week_minutes,
|
||||||
|
weekly_hours: totals.week_hours,
|
||||||
|
daily_overtime: totals.daily_overtime,
|
||||||
|
weekly_overtime: totals.weekly_overtime,
|
||||||
|
period_start: totals.period_start,
|
||||||
|
period_end: totals.period_end,
|
||||||
|
daily_threshold: totals.daily_threshold,
|
||||||
|
weekly_threshold: totals.weekly_threshold,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+627
-119
@@ -1,23 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — admin actions.
|
||||||
|
*
|
||||||
|
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor to
|
||||||
|
* run against Drizzle. Original implementation was Supabase-RPC-based
|
||||||
|
* and stubbed empty after the SaaS rebuild; the TODO comments are stale.
|
||||||
|
*
|
||||||
|
* Auth: every mutator calls `getAdminUser()` and enforces either
|
||||||
|
* `platform_admin` or brand-scoped permission. Reads require a brand
|
||||||
|
* context.
|
||||||
|
*
|
||||||
|
* PIN storage: the `time_tracking_workers.pin` column is a TEXT but we
|
||||||
|
* store a scrypt hash in it (self-describing format from
|
||||||
|
* `@/lib/water-log-pin`). Plaintext PINs are returned ONCE on
|
||||||
|
* create/reset and never persisted. Follow-up migration could rename
|
||||||
|
* the column to `pin_hash`.
|
||||||
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getSession } from "@/lib/auth";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
|
import {
|
||||||
|
timeTrackingWorkers,
|
||||||
|
timeTrackingTasks,
|
||||||
|
timeTrackingLogs,
|
||||||
|
timeTrackingSettings,
|
||||||
|
timeTrackingNotificationLog,
|
||||||
|
} from "@/db/schema/time-tracking";
|
||||||
|
import { hashPin, generatePin } from "@/lib/water-log-pin";
|
||||||
|
|
||||||
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
// create_time_worker, reset_time_worker_pin, update_time_worker,
|
|
||||||
// delete_time_worker, create_time_task, update_time_task,
|
|
||||||
// delete_time_task, get_worker_time_logs, update_worker_time_log,
|
|
||||||
// delete_worker_time_log, get_time_tracking_summary,
|
|
||||||
// get_time_tracking_settings, update_time_tracking_settings,
|
|
||||||
// get_time_tracking_notification_log, check_and_notify_overtime) and
|
|
||||||
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
|
|
||||||
// `time_tracking_settings`, `time_tracking_notification_log`) were
|
|
||||||
// not carried over into the SaaS rebuild's `db/schema/`. The actions
|
|
||||||
// below return empty results. To bring time tracking back, add
|
|
||||||
// the tables to `db/schema/` and re-implement against Drizzle. See
|
|
||||||
// `actions/route-trace/lots.ts` for the same pattern.
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type TimeWorker = {
|
export type TimeWorker = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +37,7 @@ export type TimeWorker = {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
|
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
|
||||||
pin: string;
|
pin: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
@@ -39,10 +52,13 @@ export type TimeTask = {
|
|||||||
unit: string;
|
unit: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
brand_id: string;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TimeLog = {
|
export type TimeLog = {
|
||||||
id: string;
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
worker_name: string;
|
worker_name: string;
|
||||||
task_id: string | null;
|
task_id: string | null;
|
||||||
@@ -57,147 +73,465 @@ export type TimeLog = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TimeSummary = {
|
export type TimeSummary = {
|
||||||
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
|
by_worker: {
|
||||||
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
|
id: string;
|
||||||
|
name: string;
|
||||||
|
entry_count: number;
|
||||||
|
total_hours: number;
|
||||||
|
}[];
|
||||||
|
by_task: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
name_es: string | null;
|
||||||
|
entry_count: number;
|
||||||
|
total_hours: number;
|
||||||
|
}[];
|
||||||
totals: { entry_count: number; total_hours: number; open_count: number };
|
totals: { entry_count: number; total_hours: number; open_count: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
|
function rowToWorker(r: typeof timeTrackingWorkers.$inferSelect): TimeWorker {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
brand_id: r.brandId,
|
||||||
|
name: r.name,
|
||||||
|
role: r.role,
|
||||||
|
lang: r.lang,
|
||||||
|
pin: r.pin,
|
||||||
|
active: r.active,
|
||||||
|
last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
|
||||||
|
created_at: r.createdAt.toISOString(),
|
||||||
|
worker_number: null, // not stored in schema
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await getSession(); // Time tracking tables not in SaaS rebuild — return empty list.
|
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
|
||||||
return [];
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
name_es: r.nameEs,
|
||||||
|
unit: r.unit,
|
||||||
|
active: r.active,
|
||||||
|
sort_order: r.sortOrder,
|
||||||
|
brand_id: r.brandId,
|
||||||
|
created_at: r.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTimeTrackingWorkers(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<TimeWorker[]> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingWorkers)
|
||||||
|
.where(eq(timeTrackingWorkers.brandId, brandId))
|
||||||
|
.orderBy(asc(timeTrackingWorkers.name));
|
||||||
|
return rows.map(rowToWorker);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTimeWorker(
|
export async function createTimeWorker(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_role = "worker",
|
role: string = "worker",
|
||||||
_lang = "en"
|
lang: string = "en",
|
||||||
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
worker?: TimeWorker;
|
||||||
|
pin?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
|
if (role !== "worker" && role !== "time_admin") {
|
||||||
|
return { success: false, error: "Role must be 'worker' or 'time_admin'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = generatePin();
|
||||||
|
const pinHash = hashPin(pin);
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(timeTrackingWorkers)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
name: trimmed,
|
||||||
|
role,
|
||||||
|
lang,
|
||||||
|
pin: pinHash,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) return { success: false, error: "Insert returned no row" };
|
||||||
|
return { success: true, worker: rowToWorker(row), pin };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
|
export async function resetTimeWorkerPin(
|
||||||
|
workerId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const pin = generatePin();
|
||||||
|
const pinHash = hashPin(pin);
|
||||||
|
|
||||||
|
// We don't know the brand_id without looking it up; resolve first via
|
||||||
|
// platform_admin (admins can reset across brands), then narrow.
|
||||||
|
const updated = await withPlatformAdmin(async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.update(timeTrackingWorkers)
|
||||||
|
.set({ pin: pinHash })
|
||||||
|
.where(eq(timeTrackingWorkers.id, workerId))
|
||||||
|
.returning({ id: timeTrackingWorkers.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) return { success: false, error: "Worker not found" };
|
||||||
|
return { success: true, pin };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTimeWorker(
|
export async function updateTimeWorker(
|
||||||
_workerId: string,
|
workerId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_role: string,
|
role: string,
|
||||||
_lang: string,
|
lang: string,
|
||||||
_active: boolean
|
active: boolean,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
|
if (role !== "worker" && role !== "time_admin") {
|
||||||
|
return { success: false, error: "Invalid role" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await withPlatformAdmin(async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.update(timeTrackingWorkers)
|
||||||
|
.set({ name: trimmed, role, lang, active })
|
||||||
|
.where(eq(timeTrackingWorkers.id, workerId))
|
||||||
|
.returning({ id: timeTrackingWorkers.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return { success: false, error: "Worker not found" };
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
|
export async function deleteTimeWorker(
|
||||||
|
workerId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const result = await withPlatformAdmin(async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.delete(timeTrackingWorkers)
|
||||||
|
.where(eq(timeTrackingWorkers.id, workerId))
|
||||||
|
.returning({ id: timeTrackingWorkers.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return { success: false, error: "Worker not found" };
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
// ── Tasks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
|
export async function getTimeTrackingTasks(
|
||||||
|
brandId: string,
|
||||||
await getSession(); return [];
|
activeOnly = false,
|
||||||
|
): Promise<TimeTask[]> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const baseWhere = eq(timeTrackingTasks.brandId, brandId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingTasks)
|
||||||
|
.where(activeOnly ? and(baseWhere, eq(timeTrackingTasks.active, true)) : baseWhere)
|
||||||
|
.orderBy(asc(timeTrackingTasks.sortOrder), asc(timeTrackingTasks.name));
|
||||||
|
return rows.map(rowToTask);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTimeTask(
|
export async function createTimeTask(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_nameEs: string | null = null,
|
nameEs: string | null = null,
|
||||||
_unit = "hours",
|
unit: string = "hours",
|
||||||
_sortOrder = 0
|
sortOrder = 0,
|
||||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
|
if (!["hours", "pieces", "units"].includes(unit)) {
|
||||||
|
return { success: false, error: "Unit must be 'hours', 'pieces', or 'units'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(timeTrackingTasks)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
name: trimmed,
|
||||||
|
nameEs: nameEs?.trim() || null,
|
||||||
|
unit: unit as "hours" | "pieces" | "units",
|
||||||
|
sortOrder,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
.returning({ id: timeTrackingTasks.id });
|
||||||
|
if (!row) return { success: false, error: "Insert returned no row" };
|
||||||
|
return { success: true, id: row.id };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTimeTask(
|
export async function updateTimeTask(
|
||||||
_taskId: string,
|
taskId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_nameEs: string,
|
nameEs: string,
|
||||||
_unit: string,
|
unit: string,
|
||||||
_active: boolean,
|
active: boolean,
|
||||||
_sortOrder: number
|
sortOrder: number,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: "Name is required" };
|
||||||
|
if (!["hours", "pieces", "units"].includes(unit)) {
|
||||||
|
return { success: false, error: "Invalid unit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await withPlatformAdmin(async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.update(timeTrackingTasks)
|
||||||
|
.set({
|
||||||
|
name: trimmed,
|
||||||
|
nameEs: nameEs?.trim() || null,
|
||||||
|
unit: unit as "hours" | "pieces" | "units",
|
||||||
|
active,
|
||||||
|
sortOrder,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingTasks.id, taskId))
|
||||||
|
.returning({ id: timeTrackingTasks.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return { success: false, error: "Task not found" };
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
|
export async function deleteTimeTask(
|
||||||
|
taskId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
const result = await withPlatformAdmin(async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.delete(timeTrackingTasks)
|
||||||
|
.where(eq(timeTrackingTasks.id, taskId))
|
||||||
|
.returning({ id: timeTrackingTasks.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return { success: false, error: "Task not found" };
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
// ── Time Logs (read-only — writes happen via field actions) ─────────────────
|
||||||
|
|
||||||
|
type LogListOptions = {
|
||||||
|
workerId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
start?: string; // ISO timestamp
|
||||||
|
end?: string; // ISO timestamp
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getWorkerTimeLogs(
|
export async function getWorkerTimeLogs(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_options: {
|
options: LogListOptions = {},
|
||||||
workerId?: string;
|
|
||||||
taskId?: string;
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
} = {}
|
|
||||||
): Promise<TimeLog[]> {
|
): Promise<TimeLog[]> {
|
||||||
|
const { workerId, taskId, start, end, limit = 200, offset = 0 } = options;
|
||||||
|
|
||||||
await getSession(); return [];
|
return withBrand(brandId, async (db) => {
|
||||||
|
const conditions = [eq(timeTrackingLogs.brandId, brandId)];
|
||||||
|
if (workerId)
|
||||||
|
conditions.push(eq(timeTrackingLogs.workerId, workerId));
|
||||||
|
if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId));
|
||||||
|
if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start)));
|
||||||
|
if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end)));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingLogs.id,
|
||||||
|
brand_id: timeTrackingLogs.brandId,
|
||||||
|
worker_id: timeTrackingLogs.workerId,
|
||||||
|
worker_name: timeTrackingWorkers.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,
|
||||||
|
created_at: timeTrackingLogs.createdAt,
|
||||||
|
})
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.leftJoin(
|
||||||
|
timeTrackingWorkers,
|
||||||
|
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
|
||||||
|
)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(timeTrackingLogs.clockIn))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
brand_id: r.brand_id,
|
||||||
|
worker_id: r.worker_id,
|
||||||
|
worker_name: r.worker_name ?? "(deleted worker)",
|
||||||
|
task_id: r.task_id,
|
||||||
|
task_name: r.task_name,
|
||||||
|
clock_in: r.clock_in.toISOString(),
|
||||||
|
clock_out: r.clock_out ? r.clock_out.toISOString() : null,
|
||||||
|
lunch_break_minutes: r.lunch_break_minutes,
|
||||||
|
notes: r.notes,
|
||||||
|
submitted_via: r.submitted_via,
|
||||||
|
total_minutes: computeTotalMinutes(
|
||||||
|
r.clock_in,
|
||||||
|
r.clock_out,
|
||||||
|
r.lunch_break_minutes,
|
||||||
|
),
|
||||||
|
created_at: r.created_at.toISOString(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateWorkerTimeLog(
|
function computeTotalMinutes(
|
||||||
_logId: string,
|
clockIn: Date,
|
||||||
_taskName: string,
|
clockOut: Date | null,
|
||||||
_clockIn: string,
|
lunchMinutes: number,
|
||||||
_clockOut: string | null,
|
): number {
|
||||||
_lunchMinutes: number,
|
if (!clockOut) return 0;
|
||||||
_notes: string | null
|
const ms = clockOut.getTime() - clockIn.getTime();
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTimeTrackingSummary(
|
export async function getTimeTrackingSummary(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_start: string,
|
start: string,
|
||||||
_end: string
|
end: string,
|
||||||
): Promise<TimeSummary> {
|
): Promise<TimeSummary> {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
|
||||||
await getSession(); return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
return withBrand(brandId, async (db) => {
|
||||||
|
const logs = await db
|
||||||
|
.select({
|
||||||
|
workerId: timeTrackingLogs.workerId,
|
||||||
|
workerName: timeTrackingWorkers.name,
|
||||||
|
taskId: timeTrackingLogs.taskId,
|
||||||
|
taskName: timeTrackingLogs.taskName,
|
||||||
|
clockIn: timeTrackingLogs.clockIn,
|
||||||
|
clockOut: timeTrackingLogs.clockOut,
|
||||||
|
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
|
||||||
|
})
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.leftJoin(
|
||||||
|
timeTrackingWorkers,
|
||||||
|
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, brandId),
|
||||||
|
gte(timeTrackingLogs.clockIn, startDate),
|
||||||
|
lte(timeTrackingLogs.clockIn, endDate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byWorkerMap = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; entry_count: number; total_hours: number }
|
||||||
|
>();
|
||||||
|
const byTaskMap = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; entry_count: number; total_hours: number }
|
||||||
|
>();
|
||||||
|
let totalMinutes = 0;
|
||||||
|
let openCount = 0;
|
||||||
|
|
||||||
|
for (const l of logs) {
|
||||||
|
const minutes = computeTotalMinutes(
|
||||||
|
l.clockIn,
|
||||||
|
l.clockOut,
|
||||||
|
l.lunchBreakMinutes,
|
||||||
|
);
|
||||||
|
if (!l.clockOut) {
|
||||||
|
openCount += 1;
|
||||||
|
} else {
|
||||||
|
totalMinutes += minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (l.workerId) {
|
||||||
|
const prev = byWorkerMap.get(l.workerId) ?? {
|
||||||
|
name: l.workerName ?? "(deleted)",
|
||||||
|
entry_count: 0,
|
||||||
|
total_hours: 0,
|
||||||
|
};
|
||||||
|
prev.entry_count += 1;
|
||||||
|
prev.total_hours += minutes / 60;
|
||||||
|
byWorkerMap.set(l.workerId, prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (l.taskId) {
|
||||||
|
const prev = byTaskMap.get(l.taskId) ?? {
|
||||||
|
name: l.taskName,
|
||||||
|
entry_count: 0,
|
||||||
|
total_hours: 0,
|
||||||
|
};
|
||||||
|
prev.entry_count += 1;
|
||||||
|
prev.total_hours += minutes / 60;
|
||||||
|
byTaskMap.set(l.taskId, prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
by_worker: Array.from(byWorkerMap.entries()).map(([id, v]) => ({
|
||||||
|
id,
|
||||||
|
name: v.name,
|
||||||
|
entry_count: v.entry_count,
|
||||||
|
total_hours: Math.round(v.total_hours * 100) / 100,
|
||||||
|
})),
|
||||||
|
by_task: Array.from(byTaskMap.entries()).map(([id, v]) => ({
|
||||||
|
id,
|
||||||
|
name: v.name,
|
||||||
|
name_es: null,
|
||||||
|
entry_count: v.entry_count,
|
||||||
|
total_hours: Math.round(v.total_hours * 100) / 100,
|
||||||
|
})),
|
||||||
|
totals: {
|
||||||
|
entry_count: logs.length,
|
||||||
|
total_hours: Math.round((totalMinutes / 60) * 100) / 100,
|
||||||
|
open_count: openCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Time Tracking Settings ─────────────────────────────────────────────────────
|
// ── Time Tracking Settings ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TimeTrackingSettings = {
|
export type TimeTrackingSettings = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
|
|||||||
brand_name: string;
|
brand_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
|
function rowToSettings(
|
||||||
|
r: typeof timeTrackingSettings.$inferSelect,
|
||||||
|
): TimeTrackingSettings {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
brand_id: r.brandId,
|
||||||
|
pay_period_start_day: r.payPeriodStartDay,
|
||||||
|
pay_period_length_days: r.payPeriodLengthDays,
|
||||||
|
daily_overtime_threshold: Number(r.dailyOvertimeThreshold),
|
||||||
|
weekly_overtime_threshold: Number(r.weeklyOvertimeThreshold),
|
||||||
|
overtime_multiplier: Number(r.overtimeMultiplier),
|
||||||
|
overtime_notifications: r.overtimeNotifications,
|
||||||
|
// These don't exist on the schema row; defaults match the original.
|
||||||
|
notification_emails: [],
|
||||||
|
notification_sms_numbers: [],
|
||||||
|
enable_daily_alerts: r.overtimeNotifications,
|
||||||
|
enable_weekly_alerts: r.overtimeNotifications,
|
||||||
|
daily_alert_threshold: Number(r.dailyOvertimeThreshold),
|
||||||
|
weekly_alert_threshold: Number(r.weeklyOvertimeThreshold),
|
||||||
|
send_end_of_period_summary: false,
|
||||||
|
brand_name: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await getSession(); // Real RPC not in SaaS rebuild.
|
export async function getTimeTrackingSettings(
|
||||||
return null;
|
brandId: string,
|
||||||
|
): Promise<TimeTrackingSettings | null> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSettings)
|
||||||
|
.where(eq(timeTrackingSettings.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
return row ? rowToSettings(row) : null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTimeTrackingSettings(
|
export async function updateTimeTrackingSettings(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_settings: {
|
settings: {
|
||||||
pay_period_start_day: number;
|
pay_period_start_day: number;
|
||||||
pay_period_length_days: number;
|
pay_period_length_days: number;
|
||||||
daily_overtime_threshold: number;
|
daily_overtime_threshold: number;
|
||||||
weekly_overtime_threshold: number;
|
weekly_overtime_threshold: number;
|
||||||
overtime_multiplier: number;
|
overtime_multiplier: number;
|
||||||
overtime_notifications: boolean;
|
overtime_notifications: boolean;
|
||||||
|
// Notification targets are accepted for back-compat with the UI
|
||||||
|
// form but currently aren't persisted (the columns aren't on the
|
||||||
|
// schema). Future migration can add them.
|
||||||
notification_emails?: string[];
|
notification_emails?: string[];
|
||||||
notification_sms_numbers?: string[];
|
notification_sms_numbers?: string[];
|
||||||
enable_daily_alerts?: boolean;
|
enable_daily_alerts?: boolean;
|
||||||
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
|
|||||||
weekly_alert_threshold?: number;
|
weekly_alert_threshold?: number;
|
||||||
send_end_of_period_summary?: boolean;
|
send_end_of_period_summary?: boolean;
|
||||||
brand_name?: string;
|
brand_name?: string;
|
||||||
}
|
},
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// Upsert by brand_id (which is also a UNIQUE column on the table).
|
||||||
|
await db
|
||||||
|
.insert(timeTrackingSettings)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
payPeriodStartDay: settings.pay_period_start_day,
|
||||||
|
payPeriodLengthDays: settings.pay_period_length_days,
|
||||||
|
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
|
||||||
|
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
|
||||||
|
overtimeMultiplier: String(settings.overtime_multiplier),
|
||||||
|
overtimeNotifications: settings.overtime_notifications,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: timeTrackingSettings.brandId,
|
||||||
|
set: {
|
||||||
|
payPeriodStartDay: settings.pay_period_start_day,
|
||||||
|
payPeriodLengthDays: settings.pay_period_length_days,
|
||||||
|
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
|
||||||
|
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
|
||||||
|
overtimeMultiplier: String(settings.overtime_multiplier),
|
||||||
|
overtimeNotifications: settings.overtime_notifications,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification Log ─────────────────────────────────────────────────────────
|
// ── Notification Log ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type NotificationLogEntry = {
|
export type NotificationLogEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingNotificationLog(
|
export async function getTimeTrackingNotificationLog(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_limit = 100
|
limit = 100,
|
||||||
): Promise<NotificationLogEntry[]> {
|
): Promise<NotificationLogEntry[]> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: timeTrackingNotificationLog.id,
|
||||||
|
workerId: timeTrackingNotificationLog.workerId,
|
||||||
|
workerName: timeTrackingWorkers.name,
|
||||||
|
notificationType: timeTrackingNotificationLog.notificationType,
|
||||||
|
recipient: timeTrackingNotificationLog.recipient,
|
||||||
|
status: timeTrackingNotificationLog.status,
|
||||||
|
createdAt: timeTrackingNotificationLog.createdAt,
|
||||||
|
})
|
||||||
|
.from(timeTrackingNotificationLog)
|
||||||
|
.leftJoin(
|
||||||
|
timeTrackingWorkers,
|
||||||
|
eq(timeTrackingWorkers.id, timeTrackingNotificationLog.workerId),
|
||||||
|
)
|
||||||
|
.where(eq(timeTrackingNotificationLog.brandId, brandId))
|
||||||
|
.orderBy(desc(timeTrackingNotificationLog.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
await getSession(); return [];
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
worker_id: r.workerId,
|
||||||
|
worker_name: r.workerName ?? null,
|
||||||
|
trigger_type: r.notificationType,
|
||||||
|
threshold_hours: null,
|
||||||
|
actual_hours: null,
|
||||||
|
emails_sent: r.status === "sent" ? [r.recipient] : [],
|
||||||
|
sms_numbers_sent: [],
|
||||||
|
email_sent: r.status === "sent",
|
||||||
|
sms_sent: false,
|
||||||
|
error_message: r.status === "failed" ? r.recipient : null,
|
||||||
|
created_at: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers exported for the notifications action ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute pay-period totals (used by `getWorkerPayPeriodHours` in
|
||||||
|
* field.ts and by `checkAndNotifyOvertime`). Returns hours/minutes
|
||||||
|
* for the period-to-date, today, and this week, plus overtime flags.
|
||||||
|
*/
|
||||||
|
export async function getWorkerPeriodTotals(
|
||||||
|
brandId: string,
|
||||||
|
workerId: string,
|
||||||
|
settings: TimeTrackingSettings | null,
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfDay = new Date(now);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
||||||
|
startOfWeek.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Pay period start: walk back `pay_period_length_days` from today
|
||||||
|
// until we land on a day whose day-of-month matches
|
||||||
|
// `pay_period_start_day`.
|
||||||
|
const payLen = settings?.pay_period_length_days ?? 7;
|
||||||
|
const startOfPeriod = new Date(now);
|
||||||
|
startOfPeriod.setDate(
|
||||||
|
now.getDate() - ((payLen - 1) - (now.getDate() % payLen)),
|
||||||
|
);
|
||||||
|
startOfPeriod.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const logs = await db
|
||||||
|
.select({
|
||||||
|
clockIn: timeTrackingLogs.clockIn,
|
||||||
|
clockOut: timeTrackingLogs.clockOut,
|
||||||
|
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
|
||||||
|
})
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingLogs.brandId, brandId),
|
||||||
|
eq(timeTrackingLogs.workerId, workerId),
|
||||||
|
gte(timeTrackingLogs.clockIn, startOfPeriod),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let periodMinutes = 0;
|
||||||
|
let dayMinutes = 0;
|
||||||
|
let weekMinutes = 0;
|
||||||
|
|
||||||
|
for (const l of logs) {
|
||||||
|
const minutes = computeTotalMinutes(
|
||||||
|
l.clockIn,
|
||||||
|
l.clockOut,
|
||||||
|
l.lunchBreakMinutes,
|
||||||
|
);
|
||||||
|
periodMinutes += minutes;
|
||||||
|
if (l.clockIn >= startOfDay) dayMinutes += minutes;
|
||||||
|
if (l.clockIn >= startOfWeek) weekMinutes += minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyThreshold = settings?.daily_overtime_threshold ?? 8;
|
||||||
|
const weeklyThreshold = settings?.weekly_overtime_threshold ?? 40;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period_start: startOfPeriod.toISOString(),
|
||||||
|
period_end: now.toISOString(),
|
||||||
|
period_minutes: periodMinutes,
|
||||||
|
period_hours: Math.round((periodMinutes / 60) * 100) / 100,
|
||||||
|
day_minutes: dayMinutes,
|
||||||
|
day_hours: Math.round((dayMinutes / 60) * 100) / 100,
|
||||||
|
week_minutes: weekMinutes,
|
||||||
|
week_hours: Math.round((weekMinutes / 60) * 100) / 100,
|
||||||
|
daily_overtime: dayMinutes / 60 > dailyThreshold,
|
||||||
|
weekly_overtime: weekMinutes / 60 > weeklyThreshold,
|
||||||
|
daily_threshold: dailyThreshold,
|
||||||
|
weekly_threshold: weeklyThreshold,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — overtime notification check.
|
||||||
|
*
|
||||||
|
* Re-implemented in Cycle 2 against Drizzle. The original was a stub
|
||||||
|
* that returned `{ sent: false }`. The cron-style caller
|
||||||
|
* (`/api/time-tracking/notify`) invokes this after every clock-in/out
|
||||||
|
* to decide whether to alert the brand's configured recipients.
|
||||||
|
*
|
||||||
|
* For now we INSERT into the notification log on every check; the
|
||||||
|
* actual email/SMS dispatch is intentionally a no-op until the
|
||||||
|
* notification_targets columns land on `time_tracking_settings` (out
|
||||||
|
* of Cycle 2 scope). The notify API route can already read the log.
|
||||||
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getSession } from "@/lib/auth";
|
import { withBrand } from "@/db/client";
|
||||||
|
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
|
||||||
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
|
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
|
||||||
// RPC and the time-tracking notification tables are not part of the
|
|
||||||
// SaaS rebuild schema. The function below is a stub that returns
|
|
||||||
// `{ sent: false }` so the cron-style caller (`/api/time-tracking/notify`)
|
|
||||||
// degrades gracefully. See `actions/route-trace/lots.ts` for the
|
|
||||||
// same pattern.
|
|
||||||
|
|
||||||
export type OvertimeCheckResult = {
|
export type OvertimeCheckResult = {
|
||||||
sent: boolean;
|
sent: boolean;
|
||||||
trigger_type?: string;
|
trigger_type?: "daily" | "weekly" | "both";
|
||||||
message?: string;
|
message?: string;
|
||||||
notification_log_id?: string;
|
notification_log_id?: string;
|
||||||
|
daily_hours?: number;
|
||||||
|
weekly_hours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function checkAndNotifyOvertime(
|
export async function checkAndNotifyOvertime(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_workerId: string,
|
workerId: string,
|
||||||
_workerName: string,
|
workerName: string,
|
||||||
_dailyHours: number,
|
// The two hour args are accepted for back-compat with the original
|
||||||
_weeklyHours: number
|
// signature (the cron caller passes them). We re-derive from the
|
||||||
|
// logs to avoid drift, but record them for the log body.
|
||||||
|
dailyHours: number,
|
||||||
|
weeklyHours: number,
|
||||||
): Promise<OvertimeCheckResult> {
|
): Promise<OvertimeCheckResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { sent: false, message: "Not authenticated" };
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
const settings = await getTimeTrackingSettings(brandId);
|
||||||
if (!adminUser) {
|
if (!settings || !settings.overtime_notifications) {
|
||||||
return { sent: false, message: "Not authenticated" };
|
return { sent: false, message: "Notifications disabled" };
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
sent: false,
|
// Re-derive totals from the source of truth so the notification log
|
||||||
message: "Time tracking is not configured in the SaaS rebuild",
|
// matches the actuals even if the caller passed stale numbers.
|
||||||
};
|
const totals = await getWorkerPeriodTotals(brandId, workerId, settings);
|
||||||
|
|
||||||
|
const dailyHit = totals.day_hours > totals.daily_threshold;
|
||||||
|
const weeklyHit = totals.week_hours > totals.weekly_threshold;
|
||||||
|
if (!dailyHit && !weeklyHit) {
|
||||||
|
return { sent: false, message: "Below thresholds" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger: "daily" | "weekly" | "both" = dailyHit && weeklyHit
|
||||||
|
? "both"
|
||||||
|
: dailyHit
|
||||||
|
? "daily"
|
||||||
|
: "weekly";
|
||||||
|
|
||||||
|
const subject = `Overtime alert: ${workerName}`;
|
||||||
|
const body =
|
||||||
|
`${workerName} crossed the ${trigger} overtime threshold.\n` +
|
||||||
|
`Day: ${totals.day_hours.toFixed(2)} h (threshold ${totals.daily_threshold})\n` +
|
||||||
|
`Week: ${totals.week_hours.toFixed(2)} h (threshold ${totals.weekly_threshold})\n` +
|
||||||
|
`(Reported: ${dailyHours.toFixed(2)} / ${weeklyHours.toFixed(2)})`;
|
||||||
|
|
||||||
|
// Insert a notification log row. status='pending' — actual email/SMS
|
||||||
|
// dispatch is out of Cycle 2 scope (notification_targets columns on
|
||||||
|
// time_tracking_settings land in a follow-up migration). A future
|
||||||
|
// cron will flip 'pending' → 'sent' / 'failed'.
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(timeTrackingNotificationLog)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
workerId,
|
||||||
|
notificationType: trigger,
|
||||||
|
recipient: "admin@pending",
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning({ id: timeTrackingNotificationLog.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
sent: true,
|
||||||
|
trigger_type: trigger,
|
||||||
|
message: body,
|
||||||
|
notification_log_id: row?.id,
|
||||||
|
daily_hours: totals.day_hours,
|
||||||
|
weekly_hours: totals.week_hours,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking — Smartsheet integration server actions.
|
||||||
|
*
|
||||||
|
* Cycle 7: per-feature config no longer holds the API token. The
|
||||||
|
* encrypted token lives in `smartsheet_workspace` (one row per brand);
|
||||||
|
* this action reads it via `resolveWorkspaceToken` and only manages
|
||||||
|
* the per-feature sheet_id + column_mapping + frequency.
|
||||||
|
*
|
||||||
|
* UI surface for the admin `/admin/water-log/settings` card
|
||||||
|
* ("Time Tracking → Smartsheet"). Mirrors
|
||||||
|
* `src/actions/water-log/smartsheet.ts`.
|
||||||
|
*
|
||||||
|
* Token handling:
|
||||||
|
* - The plaintext token NEVER crosses the server-action boundary.
|
||||||
|
* - `resolveWorkspaceToken` decrypts it locally for API calls.
|
||||||
|
* - The masked preview is fetched from the workspace.
|
||||||
|
*/
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import {
|
||||||
|
timeTrackingSmartsheetConfig,
|
||||||
|
timeTrackingSmartsheetSyncLog,
|
||||||
|
timeTrackingSmartsheetSyncQueue,
|
||||||
|
TT_SMARTSHEET_FREQUENCIES,
|
||||||
|
type TTSmartsheetColumnMapping,
|
||||||
|
type TTSmartsheetFrequency,
|
||||||
|
} from "@/db/schema/time-tracking";
|
||||||
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||||
|
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
|
||||||
|
import {
|
||||||
|
extractSheetId,
|
||||||
|
getSheetMeta,
|
||||||
|
SmartsheetApiError,
|
||||||
|
} from "@/lib/smartsheet";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
|
// ── Public types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TTSmartsheetConfigResponse = {
|
||||||
|
configured: boolean;
|
||||||
|
sheetId: string | null;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncFrequency: TTSmartsheetFrequency;
|
||||||
|
columnMapping: TTSmartsheetColumnMapping | null;
|
||||||
|
maskedToken: string | null;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
hasToken: boolean;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTSmartsheetRecentEntry = {
|
||||||
|
id: string;
|
||||||
|
logId: string | null;
|
||||||
|
smartsheetRowId: string | null;
|
||||||
|
action: "sync" | "retry" | "skip" | "queue";
|
||||||
|
success: boolean;
|
||||||
|
error: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTSmartsheetQueueSummary = {
|
||||||
|
pending: number;
|
||||||
|
syncing: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveTTSmartsheetConfigInput = {
|
||||||
|
sheetId: string;
|
||||||
|
token: string;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncFrequency: TTSmartsheetFrequency;
|
||||||
|
columnMapping: TTSmartsheetColumnMapping;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTTestConnectionResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
sheetName: string;
|
||||||
|
columnCount: number;
|
||||||
|
columns: { id: string; title: string; type: string }[];
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function requireManagePerms(): Promise<
|
||||||
|
{ ok: true } | { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { ok: false, error: "Not authenticated" };
|
||||||
|
// Time-tracking Smartsheet config is a brand-level integration
|
||||||
|
// setting — not a water-log concern. Gate on `can_manage_settings`
|
||||||
|
// until a dedicated `can_manage_time_tracking` flag exists.
|
||||||
|
if (
|
||||||
|
!adminUser.can_manage_settings &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return { ok: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Column keys + frequencies are imported from the schema and
|
||||||
|
// re-used directly — no re-export to avoid the "merged declaration"
|
||||||
|
// footgun.)
|
||||||
|
|
||||||
|
// ── Read: get config ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTTSmartsheetConfig(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<TTSmartsheetConfigResponse> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
sheetId: null,
|
||||||
|
syncEnabled: false,
|
||||||
|
syncFrequency: "hourly",
|
||||||
|
columnMapping: null,
|
||||||
|
maskedToken: null,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
hasToken: false,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const config = rows[0];
|
||||||
|
// Cycle 7: masked token + hasToken live on the workspace, not on
|
||||||
|
// the per-feature config.
|
||||||
|
const workspace = await getSmartsheetWorkspace(brandId);
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
sheetId: null,
|
||||||
|
syncEnabled: false,
|
||||||
|
syncFrequency: "hourly",
|
||||||
|
columnMapping: null,
|
||||||
|
maskedToken: workspace.maskedToken,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
hasToken: workspace.hasToken,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
sheetId: config.sheetId,
|
||||||
|
syncEnabled: config.syncEnabled,
|
||||||
|
syncFrequency: config.syncFrequency,
|
||||||
|
columnMapping: config.columnMapping,
|
||||||
|
maskedToken: workspace.maskedToken,
|
||||||
|
hasToken: workspace.hasToken,
|
||||||
|
lastSyncAt: config.lastSyncAt ? config.lastSyncAt.toISOString() : null,
|
||||||
|
lastSyncError: config.lastSyncError ?? null,
|
||||||
|
updatedAt: config.updatedAt
|
||||||
|
? config.updatedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write: save config ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveTTSmartsheetConfig(
|
||||||
|
brandId: string,
|
||||||
|
input: SaveTTSmartsheetConfigInput,
|
||||||
|
): Promise<
|
||||||
|
{ success: true; config: TTSmartsheetConfigResponse } | { success: false; error: string }
|
||||||
|
> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok) return { success: false, error: perm.error };
|
||||||
|
|
||||||
|
// Validate.
|
||||||
|
try {
|
||||||
|
// extractSheetId throws on share-link slugs and bad URLs; we
|
||||||
|
// surface that error to the caller verbatim.
|
||||||
|
extractSheetId(input.sheetId);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Invalid sheet ID",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!TT_SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `syncFrequency must be one of ${TT_SMARTSHEET_FREQUENCIES.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!input.columnMapping.log_id || !input.columnMapping.clock_in) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "columnMapping.log_id and columnMapping.clock_in are required (used for dedup)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const adminUser = (await getAdminUser()) ?? null;
|
||||||
|
const actorId = adminUser?.id ?? null;
|
||||||
|
|
||||||
|
// Cycle 7: API token is owned by the workspace. Refuse to save a
|
||||||
|
// sheet mapping if no workbook is connected.
|
||||||
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
|
if (!ws.ok && ws.reason !== "connection_disabled") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
ws.reason === "no_workspace"
|
||||||
|
? "Connect the Smartsheet workbook first (hub card above)."
|
||||||
|
: "Workspace token is unreadable — re-paste it in the hub card.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetConfig)
|
||||||
|
.set({
|
||||||
|
sheetId: extractSheetId(input.sheetId),
|
||||||
|
columnMapping: input.columnMapping,
|
||||||
|
syncFrequency: input.syncFrequency,
|
||||||
|
syncEnabled: input.syncEnabled,
|
||||||
|
updatedBy: actorId,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
|
||||||
|
} else {
|
||||||
|
await db.insert(timeTrackingSmartsheetConfig).values({
|
||||||
|
brandId,
|
||||||
|
sheetId: extractSheetId(input.sheetId),
|
||||||
|
columnMapping: input.columnMapping,
|
||||||
|
syncFrequency: input.syncFrequency,
|
||||||
|
syncEnabled: input.syncEnabled,
|
||||||
|
createdBy: actorId,
|
||||||
|
updatedBy: actorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await getTTSmartsheetConfig(brandId);
|
||||||
|
return { success: true, config: fresh };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test connection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function testTTSmartsheetConnection(
|
||||||
|
brandId: string,
|
||||||
|
input?: { sheetId?: string; token?: string },
|
||||||
|
): Promise<TTTestConnectionResult> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok) return { success: false, error: perm.error };
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const sheetId = input?.sheetId ?? existing?.sheetId ?? "";
|
||||||
|
let plaintext = "";
|
||||||
|
if (input?.token && input.token.trim().length > 0) {
|
||||||
|
plaintext = input.token.trim();
|
||||||
|
} else {
|
||||||
|
// Cycle 7: token lives on the workspace, not on the per-feature
|
||||||
|
// config. Decrypt from there.
|
||||||
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
|
if (!ws.ok) {
|
||||||
|
if (ws.reason === "no_workspace") {
|
||||||
|
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card." };
|
||||||
|
}
|
||||||
|
if (ws.reason === "connection_disabled") {
|
||||||
|
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
|
||||||
|
}
|
||||||
|
return { success: false, error: ws.error };
|
||||||
|
}
|
||||||
|
plaintext = ws.token;
|
||||||
|
}
|
||||||
|
if (!sheetId) {
|
||||||
|
return { success: false, error: "sheetId is required" };
|
||||||
|
}
|
||||||
|
if (!plaintext) {
|
||||||
|
return { success: false, error: "token is required" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = extractSheetId(sheetId);
|
||||||
|
const meta = await getSheetMeta(parsed, plaintext);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sheetName: meta.name,
|
||||||
|
columnCount: meta.columns.length,
|
||||||
|
columns: meta.columns.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
type: c.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Funnel through sanitizeError to strip any token echoes from
|
||||||
|
// Smartsheet's WWW-Authenticate headers, mirroring the
|
||||||
|
// service-layer guarantee.
|
||||||
|
const raw =
|
||||||
|
err instanceof SmartsheetApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: sanitizeTestError(raw, plaintext),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip any token echo from Smartsheet's error responses before
|
||||||
|
// returning to the client. The plaintext token is only used locally
|
||||||
|
// (decrypted for the API call) — never crosses the wire.
|
||||||
|
function sanitizeTestError(raw: string, plaintext: string): string {
|
||||||
|
if (!raw || !plaintext) return raw;
|
||||||
|
return raw.split(plaintext).join("[redacted-token]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read: recent sync attempts ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTTSmartsheetRecent(
|
||||||
|
brandId: string,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<TTSmartsheetRecentEntry[]> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok) return [];
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetSyncLog)
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncLog.brandId, brandId))
|
||||||
|
.orderBy(desc(timeTrackingSmartsheetSyncLog.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
logId: r.logId,
|
||||||
|
smartsheetRowId: r.smartsheetRowId,
|
||||||
|
action: r.action,
|
||||||
|
success: r.success,
|
||||||
|
error: r.error,
|
||||||
|
durationMs: r.durationMs,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read: queue summary ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTTSmartsheetQueueSummary(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<TTSmartsheetQueueSummary> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok)
|
||||||
|
return { pending: 0, syncing: 0, synced: 0, failed: 0 };
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
status: timeTrackingSmartsheetSyncQueue.status,
|
||||||
|
})
|
||||||
|
.from(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.brandId, brandId));
|
||||||
|
return rows.reduce<TTSmartsheetQueueSummary>(
|
||||||
|
(acc, r) => {
|
||||||
|
acc[r.status] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ pending: 0, syncing: 0, synced: 0, failed: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disable (preserves token + config, turns off sync) ─────────────────────
|
||||||
|
|
||||||
|
export async function disableTTSmartsheet(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
const perm = await requireManagePerms();
|
||||||
|
if (!perm.ok) return { success: false, error: perm.error };
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetConfig)
|
||||||
|
.set({ syncEnabled: false })
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
/**
|
/**
|
||||||
* Water Log — Smartsheet integration server actions.
|
* Water Log — Smartsheet integration server actions.
|
||||||
*
|
*
|
||||||
|
* Cycle 7: per-feature config no longer holds the API token. The
|
||||||
|
* encrypted token lives in `smartsheet_workspace` (one row per brand);
|
||||||
|
* this action reads it via `resolveWorkspaceToken` and only manages
|
||||||
|
* the per-feature sheet_id + column_mapping + frequency.
|
||||||
|
*
|
||||||
* Surface for the `/admin/water-log/settings` UI and the entry-insert
|
* Surface for the `/admin/water-log/settings` UI and the entry-insert
|
||||||
* hook in `field.ts`. Permission model mirrors the rest of the water
|
* hook in `field.ts`. Permission model mirrors the rest of the water
|
||||||
* log module: only admins with `can_manage_water_log` (or
|
* log module: only admins with `can_manage_water_log` (or
|
||||||
@@ -10,10 +15,9 @@
|
|||||||
*
|
*
|
||||||
* Token handling:
|
* Token handling:
|
||||||
* - The plaintext token NEVER crosses the server-action boundary.
|
* - The plaintext token NEVER crosses the server-action boundary.
|
||||||
* - Save actions accept a token via `input.token` only when the user
|
* - `resolveWorkspaceToken` decrypts it locally for API calls.
|
||||||
* types a new one. An empty token means "keep the saved one".
|
* - The masked preview is fetched from the workspace (not the
|
||||||
* - Read actions return `maskedToken` ("••••abcd") and never the
|
* per-feature config).
|
||||||
* plaintext.
|
|
||||||
*/
|
*/
|
||||||
import { desc, eq, and, gte, inArray } from "drizzle-orm";
|
import { desc, eq, and, gte, inArray } from "drizzle-orm";
|
||||||
import { withBrand } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
@@ -28,7 +32,8 @@ import {
|
|||||||
type SmartsheetColumnMapping,
|
type SmartsheetColumnMapping,
|
||||||
type SmartsheetFrequency,
|
type SmartsheetFrequency,
|
||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||||
|
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
|
||||||
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
|
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
|
||||||
import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
|
import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
|
|||||||
|
|
||||||
export type SaveSmartsheetConfigInput = {
|
export type SaveSmartsheetConfigInput = {
|
||||||
sheetId: string;
|
sheetId: string;
|
||||||
/** Plaintext token. Empty string = keep the saved token. */
|
/** Cycle 7: token is owned by the workspace; this field is kept for
|
||||||
token: string;
|
* backward compat with the existing UI but ignored on save. */
|
||||||
|
token?: string;
|
||||||
syncEnabled: boolean;
|
syncEnabled: boolean;
|
||||||
syncFrequency: SmartsheetFrequency;
|
syncFrequency: SmartsheetFrequency;
|
||||||
columnMapping: SmartsheetColumnMapping;
|
columnMapping: SmartsheetColumnMapping;
|
||||||
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
|
|||||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const config = rows[0];
|
const config = rows[0];
|
||||||
|
// Pull the masked token + hasToken from the workspace (Cycle 7).
|
||||||
|
// If there's no workspace yet, maskedToken is null and hasToken
|
||||||
|
// is false — the UI shows "Connect a workbook" in the hub card.
|
||||||
|
const workspace = await getSmartsheetWorkspace(brandId);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {
|
return {
|
||||||
configured: false,
|
configured: false,
|
||||||
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
|
|||||||
syncEnabled: false,
|
syncEnabled: false,
|
||||||
syncFrequency: "hourly",
|
syncFrequency: "hourly",
|
||||||
columnMapping: null,
|
columnMapping: null,
|
||||||
maskedToken: null,
|
maskedToken: workspace.maskedToken,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
lastSyncError: null,
|
lastSyncError: null,
|
||||||
hasToken: false,
|
hasToken: workspace.hasToken,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Decrypt just to compute the masked preview — never return the
|
|
||||||
// plaintext. If decryption fails (key rotated), we still report
|
|
||||||
// `hasToken: true` so the admin knows to re-enter it.
|
|
||||||
let masked: string | null = "••••";
|
|
||||||
let hasToken = true;
|
|
||||||
try {
|
|
||||||
const plain = decryptToken({
|
|
||||||
cipher: config.encryptedApiToken,
|
|
||||||
iv: config.tokenIv,
|
|
||||||
authTag: config.tokenAuthTag,
|
|
||||||
});
|
|
||||||
masked = maskToken(plain);
|
|
||||||
} catch {
|
|
||||||
hasToken = false;
|
|
||||||
masked = null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
sheetId: config.sheetId,
|
sheetId: config.sheetId,
|
||||||
syncEnabled: config.syncEnabled,
|
syncEnabled: config.syncEnabled,
|
||||||
syncFrequency: config.syncFrequency,
|
syncFrequency: config.syncFrequency,
|
||||||
columnMapping: config.columnMapping,
|
columnMapping: config.columnMapping,
|
||||||
maskedToken: masked,
|
maskedToken: workspace.maskedToken,
|
||||||
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
|
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
|
||||||
lastSyncError: config.lastSyncError,
|
lastSyncError: config.lastSyncError,
|
||||||
hasToken,
|
hasToken: workspace.hasToken,
|
||||||
updatedAt: config.updatedAt.toISOString(),
|
updatedAt: config.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
|
|||||||
if (!mappingResult.ok) {
|
if (!mappingResult.ok) {
|
||||||
return { success: false, error: mappingResult.error };
|
return { success: false, error: mappingResult.error };
|
||||||
}
|
}
|
||||||
// The token: empty means "keep existing" (we won't touch the
|
|
||||||
// encrypted columns). Non-empty means we re-encrypt and replace.
|
// Cycle 7: the API token is owned by the workspace, not by this
|
||||||
const tokenTrimmed = input.token?.trim() ?? "";
|
// per-feature config. Refuse to save a sheet mapping if no
|
||||||
|
// workbook is connected — the customer would have nothing to
|
||||||
|
// authenticate against at sync time.
|
||||||
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
|
if (!ws.ok && ws.reason !== "connection_disabled") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
ws.reason === "no_workspace"
|
||||||
|
? "Connect the Smartsheet workbook first (hub card above)."
|
||||||
|
: "Workspace token is unreadable — re-paste it in the hub card.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return withBrand(brandId, async (db) => {
|
return withBrand(brandId, async (db) => {
|
||||||
// ── Load existing to know if we have a token already ──
|
|
||||||
const existingRows = await db
|
const existingRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(waterSmartsheetConfig)
|
.from(waterSmartsheetConfig)
|
||||||
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0];
|
const existing = existingRows[0];
|
||||||
|
|
||||||
if (!existing && tokenTrimmed.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "API token is required for first-time setup",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (existing && tokenTrimmed.length === 0 && !existing.encryptedApiToken) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "API token is required (none on file)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Build the upsert payload ──
|
|
||||||
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
|
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
|
||||||
sheetId: normalizedSheetId,
|
sheetId: normalizedSheetId,
|
||||||
syncEnabled: input.syncEnabled,
|
syncEnabled: input.syncEnabled,
|
||||||
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
|
|||||||
columnMapping: input.columnMapping,
|
columnMapping: input.columnMapping,
|
||||||
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
};
|
};
|
||||||
if (tokenTrimmed.length > 0) {
|
|
||||||
const enc = encryptToken(tokenTrimmed);
|
|
||||||
updateValues.encryptedApiToken = enc.cipher;
|
|
||||||
updateValues.tokenIv = enc.iv;
|
|
||||||
updateValues.tokenAuthTag = enc.authTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert.
|
if (existing) {
|
||||||
const [upserted] = await db
|
await db
|
||||||
.insert(waterSmartsheetConfig)
|
.update(waterSmartsheetConfig)
|
||||||
.values({
|
.set(updateValues)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId));
|
||||||
|
} else {
|
||||||
|
await db.insert(waterSmartsheetConfig).values({
|
||||||
brandId,
|
brandId,
|
||||||
sheetId: normalizedSheetId,
|
sheetId: normalizedSheetId,
|
||||||
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
|
|
||||||
tokenIv: existing?.tokenIv ?? Buffer.from([]),
|
|
||||||
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
|
|
||||||
columnMapping: input.columnMapping,
|
columnMapping: input.columnMapping,
|
||||||
syncEnabled: input.syncEnabled,
|
syncEnabled: input.syncEnabled,
|
||||||
syncFrequency: input.syncFrequency,
|
syncFrequency: input.syncFrequency,
|
||||||
createdBy: existing?.createdBy ?? auth.adminUser.email ?? auth.adminUser.id ?? null,
|
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
})
|
});
|
||||||
.onConflictDoUpdate({
|
}
|
||||||
target: waterSmartsheetConfig.brandId,
|
|
||||||
set: updateValues,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
if (!upserted) return { success: false, error: "Save failed" };
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
brandId,
|
brandId,
|
||||||
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
|
|||||||
sheetId: normalizedSheetId,
|
sheetId: normalizedSheetId,
|
||||||
syncEnabled: input.syncEnabled,
|
syncEnabled: input.syncEnabled,
|
||||||
syncFrequency: input.syncFrequency,
|
syncFrequency: input.syncFrequency,
|
||||||
tokenRotated: tokenTrimmed.length > 0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-read for a clean response.
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
config: await getSmartsheetConfig(brandId),
|
config: await getSmartsheetConfig(brandId),
|
||||||
@@ -326,12 +304,17 @@ export async function deleteSmartsheetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test the Smartsheet connection without saving.
|
* Test the Smartsheet connection against a sheet ID, using the
|
||||||
|
* workspace's saved token.
|
||||||
*
|
*
|
||||||
* - If `token` is non-empty, it's used directly (the typical flow
|
* - If `token` is non-empty, it's used directly (test the hub card
|
||||||
* for the "Test Connection" button on a fresh form).
|
* before saving).
|
||||||
* - If `token` is empty and a token is saved, decrypt + test that.
|
* - If `token` is empty, the workspace token is decrypted and used.
|
||||||
* - Returns the sheet name + columns on success.
|
*
|
||||||
|
* Cycle 7: the token is no longer saved per-feature — it's the
|
||||||
|
* workspace's job. This action exists for the per-feature "Test
|
||||||
|
* Connection" button that re-uses the same UI affordance; the
|
||||||
|
* underlying token always comes from `smartsheet_workspace`.
|
||||||
*
|
*
|
||||||
* Does NOT throw on API errors — returns a structured failure so the
|
* Does NOT throw on API errors — returns a structured failure so the
|
||||||
* UI can show a helpful message.
|
* UI can show a helpful message.
|
||||||
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
|
|||||||
return { success: false, error: (err as Error).message };
|
return { success: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the token: explicit > saved > error.
|
// Cycle 7: resolve the token from the workspace, not from the
|
||||||
|
// per-feature config. If `tokenInput` is provided (the user is
|
||||||
|
// pasting a fresh token for a Test-Connection-from-form flow),
|
||||||
|
// honor it. Otherwise decrypt the saved workspace token.
|
||||||
let token = tokenInput?.trim() ?? "";
|
let token = tokenInput?.trim() ?? "";
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const saved = await withBrand(brandId, async (db) => {
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
const rows = await db
|
if (!ws.ok) {
|
||||||
.select()
|
if (ws.reason === "no_workspace") {
|
||||||
.from(waterSmartsheetConfig)
|
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card above." };
|
||||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
}
|
||||||
.limit(1);
|
if (ws.reason === "connection_disabled") {
|
||||||
return rows[0] ?? null;
|
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
|
||||||
});
|
}
|
||||||
if (!saved) {
|
return { success: false, error: ws.error };
|
||||||
return { success: false, error: "No token saved yet — paste one above to test." };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
token = decryptToken({
|
|
||||||
cipher: saved.encryptedApiToken,
|
|
||||||
iv: saved.tokenIv,
|
|
||||||
authTag: saved.tokenAuthTag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
|
|
||||||
}
|
}
|
||||||
|
token = ws.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel";
|
import WaterLogShell from "@/components/admin/water-log/Shell";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
|
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() {
|
|||||||
className="px-6 pt-6"
|
className="px-6 pt-6"
|
||||||
/>
|
/>
|
||||||
<div className="px-6 pb-8">
|
<div className="px-6 pb-8">
|
||||||
<WaterLogAdminPanel
|
<WaterLogShell
|
||||||
initialUsers={users}
|
initialUsers={users}
|
||||||
initialHeadgates={headgates}
|
initialHeadgates={headgates}
|
||||||
initialEntries={entries}
|
initialEntries={entries}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import {
|
|||||||
regenerateAdminPin,
|
regenerateAdminPin,
|
||||||
type AdminSettings,
|
type AdminSettings,
|
||||||
} from "@/actions/water-log/settings";
|
} from "@/actions/water-log/settings";
|
||||||
|
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
|
||||||
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
|
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
|
||||||
|
import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard";
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
@@ -475,8 +477,10 @@ export default function WaterLogSettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Smartsheet Integration — independent of the Admin Portal toggle above.
|
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
|
||||||
Self-contained card with its own save / test / activity surface. */}
|
The hub card owns the brand-level workbook connection (token +
|
||||||
|
test + enable); the per-feature cards below pick which sheet
|
||||||
|
inside the workbook to write to. */}
|
||||||
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
|
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
|
||||||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
||||||
§ 05 — Integrations
|
§ 05 — Integrations
|
||||||
@@ -488,14 +492,31 @@ export default function WaterLogSettingsPage() {
|
|||||||
Smartsheet
|
Smartsheet
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||||
Push every new water log entry to a Smartsheet sheet. The
|
Connect one Smartsheet workbook and pick which sheet each
|
||||||
brand admin (you) controls the connection, frequency, and
|
feature writes to. The token is encrypted at rest; per-feature
|
||||||
column mapping. The token is encrypted at rest.
|
cards below manage only their sheet mapping.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
|
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Water Log sheet mapping — uses the workbook token from the
|
||||||
|
hub card above. Configures which sheet inside the workbook
|
||||||
|
receives water-log entries. */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Tracking sheet mapping — also uses the workbook token.
|
||||||
|
Configures which sheet inside the workbook receives
|
||||||
|
clock-out rows. */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* § 06 removed in Cycle 7 — Time Tracking sheet mapping is now
|
||||||
|
a sibling card under § 05 above (uses the workbook token). */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
allWorkers.push(workers);
|
allWorkers.push(workers);
|
||||||
allSettings.push(settings);
|
allSettings.push(settings);
|
||||||
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId })));
|
allLogs = allLogs.concat((logs as unknown as LogEntry[]).map(l => ({ ...l, brandId })));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Cron-driven Time Tracking Smartsheet sync drain.
|
||||||
|
*
|
||||||
|
* Cycle 8 — mirrors `/api/water-log/smartsheet-sync/route.ts` so
|
||||||
|
* closed clock-outs eventually land in the customer's Smartsheet
|
||||||
|
* workbook even when the worker loses connectivity between clock-out
|
||||||
|
* and the next realtime push.
|
||||||
|
*
|
||||||
|
* Hit by Vercel cron (see vercel.json entry: every 15 minutes). The
|
||||||
|
* per-frequency gating happens inside `runScheduledTTSync`:
|
||||||
|
* - realtime: the event-driven push in `clockOutWorker` covers
|
||||||
|
* most cases; the cron recovers anything that failed
|
||||||
|
* to enqueue or was retried out.
|
||||||
|
* - every_15_min: drains the queue if last sync was ≥15m ago.
|
||||||
|
* - hourly: drains the queue if last sync was ≥60m ago.
|
||||||
|
*
|
||||||
|
* Auth:
|
||||||
|
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
|
||||||
|
* or `?secret=$CRON_SECRET` query param (Vercel cron doesn't
|
||||||
|
* support either natively; the header is what curl / external
|
||||||
|
* schedulers use). Falls back to `CRON_SECRET` for cross-
|
||||||
|
* compatibility with the existing automation routes.
|
||||||
|
*
|
||||||
|
* Body (optional):
|
||||||
|
* - `{ brandId?: string }` — restrict to one brand (useful for the
|
||||||
|
* "Retry now" admin button). If omitted, runs for every active
|
||||||
|
* brand.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { withPlatformAdmin } from "@/db/client";
|
||||||
|
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
|
||||||
|
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
|
||||||
|
import {
|
||||||
|
drainOneBrand,
|
||||||
|
summarizeDrains,
|
||||||
|
} from "@/services/sync-drain-summary";
|
||||||
|
|
||||||
|
function unauthorized() {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCronSecret(): string {
|
||||||
|
// Treat empty strings as "not set" so the CRON_SECRET fallback
|
||||||
|
// works even when SMARTSHEET_CRON_SECRET is present-but-empty
|
||||||
|
// (the vi.stubEnv idiom and some hosting dashboards).
|
||||||
|
const a = process.env.SMARTSHEET_CRON_SECRET?.trim();
|
||||||
|
const b = process.env.CRON_SECRET?.trim();
|
||||||
|
if (a) return a;
|
||||||
|
if (b) return b;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(req: NextRequest): boolean {
|
||||||
|
// Read env per-request so deploys can rotate CRON_SECRET without a
|
||||||
|
// module reload.
|
||||||
|
const secret = readCronSecret();
|
||||||
|
if (!secret) {
|
||||||
|
// No secret configured: refuse in prod, allow in dev so local
|
||||||
|
// debugging doesn't need a token.
|
||||||
|
return process.env.NODE_ENV !== "production";
|
||||||
|
}
|
||||||
|
const headerToken = req.headers
|
||||||
|
.get("authorization")
|
||||||
|
?.replace(/^Bearer\s+/i, "");
|
||||||
|
const queryToken = new URL(req.url).searchParams.get("secret");
|
||||||
|
return headerToken === secret || queryToken === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to enumerate every brand with a time-tracking smartsheet
|
||||||
|
// config enabled. The workspace read happens inside
|
||||||
|
// runScheduledTTSync, but we don't have a separate
|
||||||
|
// `timeTrackingSmartsheetConfig` helper that lists "active" brands.
|
||||||
|
// Instead, we ask Postgres directly via a raw query through
|
||||||
|
// `withPlatformAdmin`.
|
||||||
|
async function listActiveBrandIds(): Promise<string[]> {
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: timeTrackingSmartsheetConfig.brandId })
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.syncEnabled, true));
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authenticate(req)) return unauthorized();
|
||||||
|
|
||||||
|
let body: { brandId?: string } = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
if (text) body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// ignore — body is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const brandIds = body.brandId
|
||||||
|
? [body.brandId]
|
||||||
|
: await listActiveBrandIds();
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
brandIds.map((brandId) => drainOneBrand(brandId, runScheduledTTSync)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = summarizeDrains(results);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: totals.failed === 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
totals,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET is also supported for easy browser testing during development.
|
||||||
|
// Same auth; in production it should be POST-only.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return POST(req);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import {
|
||||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
TUXEDO_BRAND_ID,
|
||||||
const BRAND_NAME = "Tuxedo Corn";
|
TUXEDO_BRAND_NAME,
|
||||||
const BRAND_ACCENT = "green";
|
TUXEDO_BRAND_ACCENT,
|
||||||
|
} from "@/lib/water-log/brand";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Worker Clock — Tuxedo Corn",
|
title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function TuxedoTimeClockPage() {
|
export default async function TuxedoTimeClockPage() {
|
||||||
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TimeTrackingFieldClient
|
<TimeTrackingFieldClient
|
||||||
brandId={BRAND_ID}
|
brandId={TUXEDO_BRAND_ID}
|
||||||
brandName={BRAND_NAME}
|
brandName={TUXEDO_BRAND_NAME}
|
||||||
brandAccent={BRAND_ACCENT}
|
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||||
logoUrl={logoUrl}
|
logoUrl={logoUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Cycle 6 — Field-worker sub-PWA layout. Overrides the root layout's
|
||||||
|
* `manifest`, `themeColor`, and `applicationName` so the worker PWA
|
||||||
|
* installs as "Water Log — Tuxedo" rather than "Route Commerce".
|
||||||
|
*
|
||||||
|
* Per Next.js App Router semantics, child layouts can override any
|
||||||
|
* `Metadata` field set by an ancestor — `manifest` and `themeColor`
|
||||||
|
* both qualify. The root layout still provides fonts + providers.
|
||||||
|
*/
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { FieldSWRegistration } from "@/components/pwa/FieldSWRegistration";
|
||||||
|
import { FieldInstallPrompt } from "@/components/pwa/FieldInstallPrompt";
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
themeColor: "#14532d",
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "Water Log · Tuxedo",
|
||||||
|
template: "%s · Water Log",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Field log of Tuxedo water readings + worker time tracking — PIN-protected, mobile-first.",
|
||||||
|
applicationName: "Water Log",
|
||||||
|
manifest: "/manifest-field.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Water Log",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WaterLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Client-side: register the scoped SW + show the field-
|
||||||
|
branded install prompt. Both are no-op on first paint and
|
||||||
|
only fire under user-instigated conditions. */}
|
||||||
|
<FieldSWRegistration />
|
||||||
|
<FieldInstallPrompt />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimeTrackingSmartsheetCard — admin `/admin/water-log/settings`
|
||||||
|
* card for the Time Tracking → Smartsheet integration (Cycle 5).
|
||||||
|
*
|
||||||
|
* Cycle 7: this card no longer owns the API token. The token lives
|
||||||
|
* on the workspace (one per brand, owned by the hub card above).
|
||||||
|
* This card only manages the per-feature mapping: sheet ID + column
|
||||||
|
* mapping + frequency + sync enabled + recent activity.
|
||||||
|
*
|
||||||
|
* Self-contained client component. Takes `brandId` as a prop per
|
||||||
|
* the brand-isolation convention.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import {
|
||||||
|
getTTSmartsheetConfig,
|
||||||
|
saveTTSmartsheetConfig,
|
||||||
|
testTTSmartsheetConnection,
|
||||||
|
getTTSmartsheetRecent,
|
||||||
|
disableTTSmartsheet,
|
||||||
|
getTTSmartsheetQueueSummary,
|
||||||
|
type TTSmartsheetConfigResponse,
|
||||||
|
type TTSmartsheetRecentEntry,
|
||||||
|
type TTSmartsheetQueueSummary,
|
||||||
|
} from "@/actions/time-tracking/smartsheet";
|
||||||
|
import { TT_SMARTSHEET_FREQUENCIES } from "@/db/schema/time-tracking";
|
||||||
|
import type {
|
||||||
|
TTSmartsheetColumnMapping,
|
||||||
|
TTSmartsheetColumnKey,
|
||||||
|
TTSmartsheetFrequency,
|
||||||
|
} from "@/db/schema/time-tracking";
|
||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
brandId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "saved-at"; iso: string }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
config: TTSmartsheetConfigResponse | null;
|
||||||
|
sheetIdInput: string;
|
||||||
|
frequency: TTSmartsheetFrequency;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
columns: TTColumnOption[];
|
||||||
|
columnMapping: TTSmartsheetColumnMapping;
|
||||||
|
recent: TTSmartsheetRecentEntry[];
|
||||||
|
queue: TTSmartsheetQueueSummary;
|
||||||
|
loading: boolean;
|
||||||
|
testing: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
status: Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TTColumnOption = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse }
|
||||||
|
| { type: "SET_SHEET_ID"; value: string }
|
||||||
|
| { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency }
|
||||||
|
| { type: "SET_SYNC_ENABLED"; value: boolean }
|
||||||
|
| { type: "SET_COLUMNS"; columns: NonNullable<State["columns"]> }
|
||||||
|
| { type: "SET_COL_MAPPING_VALUE"; key: TTSmartsheetColumnKey; columnId: string | null }
|
||||||
|
| { type: "SET_RECENT"; entries: TTSmartsheetRecentEntry[] }
|
||||||
|
| { type: "SET_QUEUE"; queue: TTSmartsheetQueueSummary }
|
||||||
|
| { type: "SET_LOADING"; loading: boolean }
|
||||||
|
| { type: "SET_TESTING"; value: boolean }
|
||||||
|
| { type: "SET_SAVING"; value: boolean }
|
||||||
|
| { type: "SET_STATUS"; value: Status };
|
||||||
|
|
||||||
|
const EMPTY_MAPPING: TTSmartsheetColumnMapping = {
|
||||||
|
log_id: "",
|
||||||
|
clock_in: "",
|
||||||
|
clock_out: null,
|
||||||
|
worker: null,
|
||||||
|
task: null,
|
||||||
|
hours: null,
|
||||||
|
lunch_minutes: null,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_CONFIG": {
|
||||||
|
const cfg = action.config;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
config: cfg,
|
||||||
|
sheetIdInput: cfg.sheetId ?? "",
|
||||||
|
frequency: cfg.syncFrequency,
|
||||||
|
syncEnabled: cfg.syncEnabled,
|
||||||
|
columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING },
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SET_SHEET_ID":
|
||||||
|
return { ...state, sheetIdInput: action.value };
|
||||||
|
case "SET_FREQUENCY":
|
||||||
|
return { ...state, frequency: action.value };
|
||||||
|
case "SET_SYNC_ENABLED":
|
||||||
|
return { ...state, syncEnabled: action.value };
|
||||||
|
case "SET_COLUMNS":
|
||||||
|
return { ...state, columns: action.columns };
|
||||||
|
case "SET_COL_MAPPING_VALUE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
columnMapping: {
|
||||||
|
...state.columnMapping,
|
||||||
|
[action.key]: action.columnId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_RECENT":
|
||||||
|
return { ...state, recent: action.entries };
|
||||||
|
case "SET_QUEUE":
|
||||||
|
return { ...state, queue: action.queue };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, loading: action.loading };
|
||||||
|
case "SET_TESTING":
|
||||||
|
return { ...state, testing: action.value };
|
||||||
|
case "SET_SAVING":
|
||||||
|
return { ...state, saving: action.value };
|
||||||
|
case "SET_STATUS":
|
||||||
|
return { ...state, status: action.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialState(): State {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
sheetIdInput: "",
|
||||||
|
frequency: "hourly",
|
||||||
|
syncEnabled: false,
|
||||||
|
columns: [],
|
||||||
|
columnMapping: { ...EMPTY_MAPPING },
|
||||||
|
recent: [],
|
||||||
|
queue: { pending: 0, syncing: 0, synced: 0, failed: 0 },
|
||||||
|
loading: true,
|
||||||
|
testing: false,
|
||||||
|
saving: false,
|
||||||
|
status: { kind: "idle" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeTrackingSmartsheetCard({ brandId }: Props) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
|
const [, force] = useState({}); // for non-react subscribed re-renders
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [cfg, recent, queue] = await Promise.all([
|
||||||
|
getTTSmartsheetConfig(brandId),
|
||||||
|
getTTSmartsheetRecent(brandId, 10),
|
||||||
|
getTTSmartsheetQueueSummary(brandId),
|
||||||
|
]);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: cfg });
|
||||||
|
dispatch({ type: "SET_RECENT", entries: recent });
|
||||||
|
dispatch({ type: "SET_QUEUE", queue });
|
||||||
|
}, [brandId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Cycle 7: Test Connection uses the workspace's saved token (no
|
||||||
|
// per-feature token to pass). The workspace action
|
||||||
|
// `testSmartsheetWorkspaceConnection` is the hub's button; here we
|
||||||
|
// call the per-feature test action with no token, which falls
|
||||||
|
// through to the workspace resolver.
|
||||||
|
const onTestConnection = useCallback(async () => {
|
||||||
|
dispatch({ type: "SET_TESTING", value: true });
|
||||||
|
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
|
||||||
|
const result = await testTTSmartsheetConnection(brandId, {
|
||||||
|
sheetId: state.sheetIdInput,
|
||||||
|
// No `token` — the action pulls the saved workspace token.
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_STATUS",
|
||||||
|
value: { kind: "error", message: result.error },
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_TESTING", value: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: "SET_COLUMNS", columns: result.columns });
|
||||||
|
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
|
||||||
|
dispatch({ type: "SET_TESTING", value: false });
|
||||||
|
force({});
|
||||||
|
}, [brandId, state.sheetIdInput]);
|
||||||
|
|
||||||
|
const onSave = useCallback(async () => {
|
||||||
|
dispatch({ type: "SET_SAVING", value: true });
|
||||||
|
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
|
||||||
|
const result = await saveTTSmartsheetConfig(brandId, {
|
||||||
|
sheetId: state.sheetIdInput,
|
||||||
|
// Cycle 7: token is owned by the workspace; this field is
|
||||||
|
// ignored on save but kept for type compat.
|
||||||
|
token: "",
|
||||||
|
syncEnabled: state.syncEnabled,
|
||||||
|
syncFrequency: state.frequency,
|
||||||
|
columnMapping: state.columnMapping,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_STATUS",
|
||||||
|
value: { kind: "error", message: result.error },
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_SAVING", value: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: "SET_CONFIG",
|
||||||
|
config: result.config,
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "SET_STATUS",
|
||||||
|
value: { kind: "saved-at", iso: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_SAVING", value: false });
|
||||||
|
}, [brandId, state]);
|
||||||
|
|
||||||
|
const onDisable = useCallback(async () => {
|
||||||
|
dispatch({ type: "SET_SAVING", value: true });
|
||||||
|
await disableTTSmartsheet(brandId);
|
||||||
|
await refresh();
|
||||||
|
dispatch({ type: "SET_SAVING", value: false });
|
||||||
|
dispatch({
|
||||||
|
type: "SET_STATUS",
|
||||||
|
value: { kind: "saved-at", iso: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
}, [brandId, refresh]);
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[#e6dfd1] bg-white p-6">
|
||||||
|
<div className="h-5 w-40 bg-[#fdfaf2] animate-pulse rounded" />
|
||||||
|
<div className="mt-3 h-4 w-72 bg-[#fdfaf2] animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWorkspaceToken = state.config?.hasToken === true;
|
||||||
|
const maskedToken = state.config?.maskedToken ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
|
||||||
|
data-test="tt-smartsheet-card"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-[#1a4d2e]">
|
||||||
|
Time Tracking → Smartsheet
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
|
||||||
|
Push every closed clock-out to a sheet inside the connected
|
||||||
|
Smartsheet workbook. The API token is managed by the hub
|
||||||
|
card above; this card only configures the sheet mapping.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-[#1a4d2e] accent-[#1a4d2e]"
|
||||||
|
checked={state.syncEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_SYNC_ENABLED",
|
||||||
|
value: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-test="tt-smartsheet-enabled"
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace token reference */}
|
||||||
|
<div className="mb-4 rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-xs text-[rgba(60,60,67,0.65)]">
|
||||||
|
{hasWorkspaceToken && maskedToken ? (
|
||||||
|
<>
|
||||||
|
Using workbook token{" "}
|
||||||
|
<span className="font-mono text-[#1a4d2e]">{maskedToken}</span>{" "}
|
||||||
|
from the hub card above.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
⚠ No Smartsheet workbook is connected yet. Save the hub card
|
||||||
|
above with an API token before configuring this sheet.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
|
||||||
|
Sheet ID or URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 8309876543210123"
|
||||||
|
value={state.sheetIdInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_SHEET_ID", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
|
||||||
|
data-test="tt-smartsheet-sheet-id"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
|
||||||
|
Pick the sheet inside the workbook where clock-outs should land.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
|
||||||
|
Frequency
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={state.frequency}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_FREQUENCY",
|
||||||
|
value: e.target.value as TTSmartsheetFrequency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
|
||||||
|
data-test="tt-smartsheet-frequency"
|
||||||
|
>
|
||||||
|
{TT_SMARTSHEET_FREQUENCIES.map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{f.replace(/_/g, " ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={state.testing || !state.sheetIdInput}
|
||||||
|
onClick={onTestConnection}
|
||||||
|
className="rounded-md border border-[#1a4d2e] bg-white px-3 py-1.5 text-sm font-semibold text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
data-test="tt-smartsheet-test"
|
||||||
|
>
|
||||||
|
{state.testing ? "Testing…" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={state.saving}
|
||||||
|
onClick={onSave}
|
||||||
|
className="rounded-md bg-[#1a4d2e] px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-[#14432a] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
data-test="tt-smartsheet-save"
|
||||||
|
>
|
||||||
|
{state.saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
{state.config?.configured && state.syncEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={state.saving}
|
||||||
|
onClick={onDisable}
|
||||||
|
className="rounded-md border border-[#a4452b] bg-white px-3 py-1.5 text-sm font-semibold text-[#a4452b] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs text-[rgba(60,60,67,0.55)]">
|
||||||
|
{state.status.kind === "saved-at"
|
||||||
|
? `Saved at ${formatDateTime(new Date(state.status.iso))}`
|
||||||
|
: state.status.kind === "error"
|
||||||
|
? state.status.message
|
||||||
|
: state.config?.lastSyncAt
|
||||||
|
? `Last sync ${formatDateTime(new Date(state.config.lastSyncAt))}`
|
||||||
|
: "Never synced"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.columns.length > 0 && (
|
||||||
|
<ColumnMappingPanel
|
||||||
|
columns={state.columns}
|
||||||
|
mapping={state.columnMapping}
|
||||||
|
onChange={(key, columnId) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_COL_MAPPING_VALUE",
|
||||||
|
key,
|
||||||
|
columnId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecentActivity recent={state.recent} queue={state.queue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnMappingPanel({
|
||||||
|
columns,
|
||||||
|
mapping,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
columns: NonNullable<State["columns"]>;
|
||||||
|
mapping: TTSmartsheetColumnMapping;
|
||||||
|
onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-6 rounded-xl border border-[#e6dfd1] bg-[#fdfaf2] p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h3>
|
||||||
|
<p className="mt-1 text-xs text-[rgba(60,60,67,0.65)]">
|
||||||
|
Required: <span className="font-mono">log_id</span>,{" "}
|
||||||
|
<span className="font-mono">clock_in</span>. Others optional.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["log_id", "Log ID (UUID, for dedup)", true],
|
||||||
|
["clock_in", "Clock-in (timestamp)", true],
|
||||||
|
["clock_out", "Clock-out (timestamp)", false],
|
||||||
|
["worker", "Worker name", false],
|
||||||
|
["task", "Task name", false],
|
||||||
|
["hours", "Hours (decimal)", false],
|
||||||
|
["lunch_minutes", "Lunch minutes", false],
|
||||||
|
["notes", "Notes", false],
|
||||||
|
] as Array<[TTSmartsheetColumnKey, string, boolean]>
|
||||||
|
).map(([key, label, required]) => (
|
||||||
|
<label key={key} className="block">
|
||||||
|
<span className="block text-xs font-medium text-[rgba(60,60,67,0.65)]">
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="ml-1 text-[#a4452b]">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={mapping[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
key,
|
||||||
|
e.target.value === "" ? null : e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-md border border-[#e6dfd1] bg-white px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— not mapped —</option>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title || `Column ${c.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentActivity({
|
||||||
|
recent,
|
||||||
|
queue,
|
||||||
|
}: {
|
||||||
|
recent: TTSmartsheetRecentEntry[];
|
||||||
|
queue: TTSmartsheetQueueSummary;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a4d2e]">
|
||||||
|
Recent activity
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 flex gap-3 text-xs text-[rgba(60,60,67,0.65)]">
|
||||||
|
<span>Pending: {queue.pending}</span>
|
||||||
|
<span>Syncing: {queue.syncing}</span>
|
||||||
|
<span>Synced: {queue.synced}</span>
|
||||||
|
<span>Failed: {queue.failed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto rounded-xl border border-[#e6dfd1]">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[rgba(60,60,67,0.55)]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">When</th>
|
||||||
|
<th className="px-3 py-2">Action</th>
|
||||||
|
<th className="px-3 py-2">Result</th>
|
||||||
|
<th className="px-3 py-2">Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="px-3 py-3 text-center text-[rgba(60,60,67,0.55)]"
|
||||||
|
colSpan={4}
|
||||||
|
>
|
||||||
|
No sync attempts yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{recent.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t border-[#f0e9d8]">
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{formatDateTime(new Date(r.createdAt))}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{r.action}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r.success
|
||||||
|
? "inline-block rounded-full bg-[#1a4d2e]/10 px-2 py-0.5 text-[#1a4d2e]"
|
||||||
|
: "inline-block rounded-full bg-[#a4452b]/10 px-2 py-0.5 text-[#a4452b]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.success ? "OK" : "FAIL"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{r.error ?? "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeTrackingSmartsheetCard;
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Shell — the tabbed shell for the Water Log admin.
|
||||||
|
*
|
||||||
|
* Replaces the monolithic `WaterLogAdminPanel` (which was 2117 lines
|
||||||
|
* covering 9 distinct UI concerns). The shell owns:
|
||||||
|
*
|
||||||
|
* - the reducer + initial state (delegate to ./reducer)
|
||||||
|
* - the localStorage-backed season + recipient settings
|
||||||
|
* - the "today" derived state (today's date / in-season / today's entries)
|
||||||
|
* - the tab state
|
||||||
|
* - the toast dispatcher
|
||||||
|
*
|
||||||
|
* Each tab is a focused component under ./tabs that consumes the
|
||||||
|
* state slice it needs.
|
||||||
|
*
|
||||||
|
* Adding a new tab only requires:
|
||||||
|
* 1. Create the tab component under ./tabs
|
||||||
|
* 2. Add a TabValue + tabs[] entry below
|
||||||
|
* 3. Render it conditionally in the {tab === X && ...} block
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import AdminFilterTabs from "@/components/admin/design-system/AdminFilterTabs";
|
||||||
|
import {
|
||||||
|
isInIrrigationSeason,
|
||||||
|
shapeWaterLogEntry,
|
||||||
|
type WaterLogReportRow,
|
||||||
|
} from "@/lib/water-log-reporting";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import type {
|
||||||
|
AdminEntry,
|
||||||
|
AdminHeadgate,
|
||||||
|
AdminIrrigator,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import { reducer, initState } from "./reducer";
|
||||||
|
import type { ReportPreviewData } from "./types";
|
||||||
|
import { HeaderNav } from "./shared/HeaderNav";
|
||||||
|
import { ToastNotification } from "./shared/Toast";
|
||||||
|
import { ReportPreviewModal } from "./shared/ReportPreviewModal";
|
||||||
|
import { TodayTab } from "./tabs/TodayTab";
|
||||||
|
import { HeadgatesTab } from "./tabs/HeadgatesTab";
|
||||||
|
import { UsersTab } from "./tabs/UsersTab";
|
||||||
|
import { TimeTrackingTab } from "./tabs/TimeTrackingTab";
|
||||||
|
|
||||||
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
export type ShellProps = {
|
||||||
|
initialUsers: AdminIrrigator[];
|
||||||
|
initialHeadgates: AdminHeadgate[];
|
||||||
|
initialEntries: AdminEntry[];
|
||||||
|
brandId: string;
|
||||||
|
canManage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab values ───────────────────────────────────────────────────────────────
|
||||||
|
type TabValue = "today" | "headgates" | "users" | "time-tracking";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{
|
||||||
|
value: "today" as const,
|
||||||
|
label: "Today",
|
||||||
|
// could pull counts from props for badges, omitted for now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "headgates" as const,
|
||||||
|
label: "Headgates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "users" as const,
|
||||||
|
label: "Users",
|
||||||
|
},
|
||||||
|
// Cycle 3 — embed the existing TimeTrackingAdminPanel (which has
|
||||||
|
// its own sub-tabs) so the water-log shell becomes a one-stop hub
|
||||||
|
// for the customer: today's entries, the headgates, who logs them,
|
||||||
|
// and how many hours they put in.
|
||||||
|
{
|
||||||
|
value: "time-tracking" as const,
|
||||||
|
label: "Time Tracking",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Main component ───────────────────────────────────────────────────────────
|
||||||
|
export default function Shell({
|
||||||
|
initialUsers,
|
||||||
|
initialHeadgates,
|
||||||
|
initialEntries,
|
||||||
|
brandId,
|
||||||
|
canManage,
|
||||||
|
}: ShellProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [tab, setTab] = useState<TabValue>("today");
|
||||||
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
initialHeadgates,
|
||||||
|
initialUsers,
|
||||||
|
initialEntries,
|
||||||
|
}, initState);
|
||||||
|
|
||||||
|
// Resolve "today" once on mount, in the browser, to avoid hydration
|
||||||
|
// mismatches. The setState is wrapped in an async IIFE so the effect
|
||||||
|
// doesn't call setState synchronously (which trips the cascading-
|
||||||
|
// renders ESLint rule).
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const now = new Date();
|
||||||
|
dispatch({
|
||||||
|
type: "SET_TODAY",
|
||||||
|
today: now.toISOString().slice(0, 10),
|
||||||
|
label: formatDate(now),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived data ─────────────────────────────────────────────────────────────
|
||||||
|
const inSeason = useMemo(
|
||||||
|
() => isInIrrigationSeason(new Date(), state.seasonStart),
|
||||||
|
[state.seasonStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const todayEntries = useMemo(() => {
|
||||||
|
if (!state.today) return [];
|
||||||
|
return state.entries.filter(
|
||||||
|
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === state.today,
|
||||||
|
);
|
||||||
|
}, [state.entries, state.today]);
|
||||||
|
|
||||||
|
const todayTotal = useMemo(
|
||||||
|
() => todayEntries.reduce((s, e) => s + e.measurement, 0),
|
||||||
|
[todayEntries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastEntryByHeadgate = useMemo(() => {
|
||||||
|
const map = new Map<string, AdminEntry>();
|
||||||
|
for (const e of state.entries) {
|
||||||
|
const existing = map.get(e.headgate_id);
|
||||||
|
if (!existing || e.logged_at > existing.logged_at) {
|
||||||
|
map.set(e.headgate_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [state.entries]);
|
||||||
|
|
||||||
|
// Toast helper ─────────────────────────────────────────────────────────────
|
||||||
|
function notify(msg: string, kind: "ok" | "err" = "ok") {
|
||||||
|
dispatch({ type: "SET_TOAST", toast: { msg, kind } });
|
||||||
|
setTimeout(() => dispatch({ type: "CLEAR_TOAST" }), 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persisted UI preferences (lives in the shell so the storage
|
||||||
|
// contract is owned by one place, not scattered through tabs).
|
||||||
|
function persistSeason(s: import("@/lib/water-log-reporting").IrrigationSeasonSettings) {
|
||||||
|
dispatch({ type: "SET_SEASON_START", value: s });
|
||||||
|
try {
|
||||||
|
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRecipientName(v: string) {
|
||||||
|
dispatch({ type: "SET_RECIPIENT_NAME", value: v });
|
||||||
|
try {
|
||||||
|
localStorage.setItem("wl_recipient_name", v);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report preview open/close ────────────────────────────────────────────────
|
||||||
|
function openReportPreview() {
|
||||||
|
const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry);
|
||||||
|
const unit =
|
||||||
|
rows.find((r) => r.unit)?.unit ??
|
||||||
|
state.headgates[0]?.unit ??
|
||||||
|
"CFS";
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
notify(
|
||||||
|
`No entries today — report would be skipped (season: ${
|
||||||
|
inSeason ? "in" : "out"
|
||||||
|
}).`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: ReportPreviewData = {
|
||||||
|
rows,
|
||||||
|
total: todayTotal,
|
||||||
|
unit,
|
||||||
|
dateLabel: state.todayLabel || formatDate(now),
|
||||||
|
dateIso: state.today || now.toISOString().slice(0, 10),
|
||||||
|
recipientName: state.recipientName.trim() || undefined,
|
||||||
|
inSeason,
|
||||||
|
};
|
||||||
|
dispatch({ type: "OPEN_REPORT_PREVIEW", preview });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReportPreview() {
|
||||||
|
dispatch({ type: "CLOSE_REPORT_PREVIEW" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<HeaderNav />
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<AdminFilterTabs
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(value) => setTab(value as TabValue)}
|
||||||
|
tabs={TABS.map((t) => ({
|
||||||
|
value: t.value,
|
||||||
|
label: t.label,
|
||||||
|
}))}
|
||||||
|
showCounts={false}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "today" && (
|
||||||
|
<TodayTab
|
||||||
|
brandId={brandId}
|
||||||
|
state={state}
|
||||||
|
todayLabel={state.todayLabel}
|
||||||
|
todayEntries={todayEntries}
|
||||||
|
todayTotal={todayTotal}
|
||||||
|
inSeason={inSeason}
|
||||||
|
notify={notify}
|
||||||
|
dispatch={dispatch}
|
||||||
|
onPreviewReport={openReportPreview}
|
||||||
|
onSeasonChange={persistSeason}
|
||||||
|
onRecipientNameChange={persistRecipientName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "headgates" && (
|
||||||
|
<HeadgatesTab
|
||||||
|
brandId={brandId}
|
||||||
|
headgates={state.headgates}
|
||||||
|
lastEntryByHeadgate={lastEntryByHeadgate}
|
||||||
|
canManage={canManage}
|
||||||
|
showAddHg={state.forms.showAddHg}
|
||||||
|
newHg={state.forms.newHg}
|
||||||
|
savingHg={state.forms.savingHg}
|
||||||
|
notify={notify}
|
||||||
|
dispatch={dispatch}
|
||||||
|
onEdit={(h) => router.push(`/admin/water-log/headgates/${h.id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "users" && (
|
||||||
|
<UsersTab
|
||||||
|
brandId={brandId}
|
||||||
|
users={state.users}
|
||||||
|
canManage={canManage}
|
||||||
|
showAddUser={state.forms.showAddUser}
|
||||||
|
newUser={state.forms.newUser}
|
||||||
|
savingUser={state.forms.savingUser}
|
||||||
|
newPin={state.forms.newPin}
|
||||||
|
resetPin={state.forms.resetPin}
|
||||||
|
notify={notify}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "time-tracking" && (
|
||||||
|
<TimeTrackingTab brandId={brandId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToastNotification toast={state.toast} />
|
||||||
|
|
||||||
|
<ReportPreviewModal
|
||||||
|
data={state.reportPreview}
|
||||||
|
onClose={closeReportPreview}
|
||||||
|
notify={notify}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmartsheetHub — top-level card for `/admin/water-log/settings`.
|
||||||
|
*
|
||||||
|
* Cycle 7. Owns the brand-level Smartsheet workbook connection:
|
||||||
|
* - API token (encrypted at rest in `smartsheet_workspace`)
|
||||||
|
* - "Test Connection" button — verifies the token against a sheet
|
||||||
|
* - Connection enable toggle (master kill-switch)
|
||||||
|
* - Default sheet ID (the "home" tab in the workbook)
|
||||||
|
* - Last-verified badge
|
||||||
|
*
|
||||||
|
* Per-feature sheet mappings (water log + time tracking) live in their
|
||||||
|
* own cards BELOW this one and inherit the workspace token.
|
||||||
|
*
|
||||||
|
* Visual language matches the parent Water Log settings page
|
||||||
|
* (cream + forest palette, "Field Almanac" style).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import {
|
||||||
|
getSmartsheetWorkspace,
|
||||||
|
saveSmartsheetWorkspace,
|
||||||
|
testSmartsheetWorkspaceConnection,
|
||||||
|
disableSmartsheetWorkspace,
|
||||||
|
} from "@/actions/smartsheet/workspace";
|
||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
message: { type: "success" | "error"; text: string } | null;
|
||||||
|
workspace: Awaited<ReturnType<typeof getSmartsheetWorkspace>> | null;
|
||||||
|
// Editable form fields:
|
||||||
|
tokenInput: string;
|
||||||
|
defaultSheetIdInput: string;
|
||||||
|
showToken: boolean;
|
||||||
|
connectionEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "load"; workspace: State["workspace"] }
|
||||||
|
| { type: "loading"; loading: boolean }
|
||||||
|
| { type: "saving"; saving: boolean }
|
||||||
|
| { type: "token"; value: string }
|
||||||
|
| { type: "showToken"; value: boolean }
|
||||||
|
| { type: "defaultSheet"; value: string }
|
||||||
|
| { type: "connection"; value: boolean }
|
||||||
|
| { type: "message"; message: State["message"] }
|
||||||
|
| { type: "reset-message" };
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "load":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
workspace: action.workspace,
|
||||||
|
connectionEnabled: action.workspace?.connectionEnabled ?? true,
|
||||||
|
defaultSheetIdInput: action.workspace?.defaultSheetId ?? "",
|
||||||
|
};
|
||||||
|
case "loading":
|
||||||
|
return { ...state, loading: action.loading };
|
||||||
|
case "saving":
|
||||||
|
return { ...state, saving: action.saving };
|
||||||
|
case "token":
|
||||||
|
return { ...state, tokenInput: action.value };
|
||||||
|
case "showToken":
|
||||||
|
return { ...state, showToken: action.value };
|
||||||
|
case "defaultSheet":
|
||||||
|
return { ...state, defaultSheetIdInput: action.value };
|
||||||
|
case "connection":
|
||||||
|
return { ...state, connectionEnabled: action.value };
|
||||||
|
case "message":
|
||||||
|
return { ...state, message: action.message };
|
||||||
|
case "reset-message":
|
||||||
|
return { ...state, message: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: State = {
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
message: null,
|
||||||
|
workspace: null,
|
||||||
|
tokenInput: "",
|
||||||
|
defaultSheetIdInput: "",
|
||||||
|
showToken: false,
|
||||||
|
connectionEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SmartsheetHub({ brandId }: { brandId: string }) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||||
|
const [testResult, setTestResult] = useState<
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "loading" }
|
||||||
|
| { state: "ok"; sheetName: string; columnCount: number }
|
||||||
|
| { state: "error"; message: string }
|
||||||
|
>({ state: "idle" });
|
||||||
|
|
||||||
|
// ── Load on mount + brandId change ──
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
dispatch({ type: "loading", loading: true });
|
||||||
|
try {
|
||||||
|
const ws = await getSmartsheetWorkspace(brandId);
|
||||||
|
if (!cancelled) {
|
||||||
|
dispatch({ type: "load", workspace: ws });
|
||||||
|
dispatch({ type: "loading", loading: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : "Failed to load workspace",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({ type: "loading", loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [brandId]);
|
||||||
|
|
||||||
|
// ── Save ──
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
dispatch({ type: "saving", saving: true });
|
||||||
|
dispatch({ type: "reset-message" });
|
||||||
|
try {
|
||||||
|
const defaultSheetId =
|
||||||
|
state.defaultSheetIdInput.trim().length > 0
|
||||||
|
? state.defaultSheetIdInput.trim()
|
||||||
|
: null;
|
||||||
|
const result = await saveSmartsheetWorkspace(brandId, {
|
||||||
|
token: state.tokenInput,
|
||||||
|
defaultSheetId,
|
||||||
|
connectionEnabled: state.connectionEnabled,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: { type: "error", text: result.error },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: "load", workspace: result.workspace });
|
||||||
|
// Clear the token input on success — never leave it in the form.
|
||||||
|
dispatch({ type: "token", value: "" });
|
||||||
|
dispatch({ type: "showToken", value: false });
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
type: "success",
|
||||||
|
text: state.tokenInput.length > 0
|
||||||
|
? "Workspace connected."
|
||||||
|
: "Workspace settings saved.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : "Save failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "saving", saving: false });
|
||||||
|
}
|
||||||
|
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.connectionEnabled]);
|
||||||
|
|
||||||
|
// ── Test connection ──
|
||||||
|
const handleTest = useCallback(async () => {
|
||||||
|
setTestResult({ state: "loading" });
|
||||||
|
const sheetIdInput =
|
||||||
|
state.defaultSheetIdInput.trim().length > 0
|
||||||
|
? state.defaultSheetIdInput.trim()
|
||||||
|
: state.workspace?.defaultSheetId ?? "";
|
||||||
|
if (!sheetIdInput) {
|
||||||
|
setTestResult({
|
||||||
|
state: "error",
|
||||||
|
message: "Enter a sheet ID or URL above to test against.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await testSmartsheetWorkspaceConnection(
|
||||||
|
brandId,
|
||||||
|
sheetIdInput,
|
||||||
|
state.tokenInput,
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
setTestResult({
|
||||||
|
state: "ok",
|
||||||
|
sheetName: result.sheetName,
|
||||||
|
columnCount: result.columnCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTestResult({ state: "error", message: result.error });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult({
|
||||||
|
state: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Test failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.workspace?.defaultSheetId]);
|
||||||
|
|
||||||
|
// ── Disable (no-confirm shortcut) ──
|
||||||
|
const handleDisable = useCallback(async () => {
|
||||||
|
dispatch({ type: "saving", saving: true });
|
||||||
|
try {
|
||||||
|
await disableSmartsheetWorkspace(brandId);
|
||||||
|
const ws = await getSmartsheetWorkspace(brandId);
|
||||||
|
dispatch({ type: "load", workspace: ws });
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: { type: "success", text: "Connection disabled. Sheet mappings preserved." },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : "Disable failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "saving", saving: false });
|
||||||
|
}
|
||||||
|
}, [brandId]);
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
|
||||||
|
<p className="text-sm text-[#5a5d5a]">Loading workspace…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnected = state.workspace?.configured === true;
|
||||||
|
const hasToken = state.workspace?.hasToken === true;
|
||||||
|
const maskedToken = state.workspace?.maskedToken ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
className="text-lg font-medium text-[#1a4d2e]"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
Workbook connection
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[#5a5d5a]">
|
||||||
|
One Smartsheet API token powers every feature below (water log, time
|
||||||
|
tracking, future payroll). Per-feature cards just pick which sheet
|
||||||
|
inside the workbook to write to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${
|
||||||
|
isConnected && state.connectionEnabled
|
||||||
|
? "bg-[#dfe9d4] text-[#1a4d2e]"
|
||||||
|
: isConnected
|
||||||
|
? "bg-[#f1e8d4] text-[#8a6b3b]"
|
||||||
|
: "bg-[#f0e1d8] text-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
isConnected && state.connectionEnabled
|
||||||
|
? "bg-[#1a4d2e]"
|
||||||
|
: isConnected
|
||||||
|
? "bg-[#8a6b3b]"
|
||||||
|
: "bg-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isConnected
|
||||||
|
? state.connectionEnabled
|
||||||
|
? "Connected"
|
||||||
|
: "Paused"
|
||||||
|
: "Not connected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Last verified ── */}
|
||||||
|
{isConnected && state.workspace?.lastTestAt && (
|
||||||
|
<p className="mt-3 text-xs text-[#5a5d5a]">
|
||||||
|
Last verified: {formatDateTime(new Date(state.workspace.lastTestAt))}
|
||||||
|
{state.workspace.lastTestError && (
|
||||||
|
<span className="ml-2 text-[#a4452b]">
|
||||||
|
· {state.workspace.lastTestError.slice(0, 80)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Form ── */}
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#1a4d2e]">
|
||||||
|
API token
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type={state.showToken ? "text" : "password"}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
placeholder={
|
||||||
|
hasToken && maskedToken
|
||||||
|
? `Saved: ${maskedToken} — paste to rotate`
|
||||||
|
: "Paste your Smartsheet API token"
|
||||||
|
}
|
||||||
|
value={state.tokenInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "token", value: e.target.value })
|
||||||
|
}
|
||||||
|
data-test="smartsheet-workspace-token"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#5a5d5a] hover:bg-[#fdfaf2]"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({ type: "showToken", value: !state.showToken })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{state.showToken ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||||
|
Encrypted at rest with AES-256-GCM. Never returned to the browser
|
||||||
|
after save.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#1a4d2e]">
|
||||||
|
Default sheet ID (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
placeholder="e.g. 8309876543210123 (or paste a Smartsheet URL)"
|
||||||
|
value={state.defaultSheetIdInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "defaultSheet", value: e.target.value })
|
||||||
|
}
|
||||||
|
data-test="smartsheet-workspace-default-sheet"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||||
|
The “home” tab when the customer opens the workbook.
|
||||||
|
Per-feature cards below pick their own sheets independently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#1a4d2e]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-[#d4d9d3] text-[#1a4d2e] focus:ring-[#1a4d2e]"
|
||||||
|
checked={state.connectionEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "connection", value: e.target.checked })
|
||||||
|
}
|
||||||
|
data-test="smartsheet-workspace-enabled"
|
||||||
|
/>
|
||||||
|
Connection enabled
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-[#5a5d5a]">
|
||||||
|
(Master switch — pauses all per-feature syncs without losing mappings)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ── */}
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={state.saving}
|
||||||
|
data-test="smartsheet-workspace-save"
|
||||||
|
>
|
||||||
|
{state.saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testResult.state === "loading"}
|
||||||
|
data-test="smartsheet-workspace-test"
|
||||||
|
>
|
||||||
|
{testResult.state === "loading" ? "Testing…" : "Test connection"}
|
||||||
|
</button>
|
||||||
|
{isConnected && state.connectionEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-[#d4d9d3] bg-white px-4 py-2 text-sm font-medium text-[#8a6b3b] hover:bg-[#fdfaf2] disabled:opacity-60"
|
||||||
|
onClick={handleDisable}
|
||||||
|
disabled={state.saving}
|
||||||
|
>
|
||||||
|
Pause connection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Test result ── */}
|
||||||
|
{testResult.state === "ok" && (
|
||||||
|
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
|
||||||
|
<p className="text-sm font-medium text-[#1a4d2e]">
|
||||||
|
✓ Connected to “{testResult.sheetName}”
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||||
|
{testResult.columnCount} columns visible. The test uses the
|
||||||
|
{state.tokenInput.length > 0 ? " pasted" : " saved"} token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testResult.state === "error" && (
|
||||||
|
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
|
||||||
|
<p className="text-sm font-medium text-[#a4452b]">
|
||||||
|
✗ {testResult.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Save message ── */}
|
||||||
|
{state.message && (
|
||||||
|
<div
|
||||||
|
className={`smartsheet-fade-in mt-4 rounded-lg p-3 text-sm ${
|
||||||
|
state.message.type === "success"
|
||||||
|
? "bg-[#dfe9d4] text-[#1a4d2e]"
|
||||||
|
: "bg-[#f0e1d8] text-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SmartsheetHub;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Reducer + state initialization for the Water Log admin module.
|
||||||
|
*
|
||||||
|
* Pulled out of the monolithic `WaterLogAdminPanel.tsx` so the shell can
|
||||||
|
* own state and dispatch and pass them down to focused tab components.
|
||||||
|
*
|
||||||
|
* localStorage contracts (intentionally narrow):
|
||||||
|
* - wl_season_settings:v1 → IrrigationSeasonSettings JSON
|
||||||
|
* - wl_recipient_name → string
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AdminEntry,
|
||||||
|
AdminHeadgate,
|
||||||
|
AdminIrrigator,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
|
||||||
|
import type { Action, State } from "./types";
|
||||||
|
|
||||||
|
export const DEFAULT_SEASON: IrrigationSeasonSettings = {
|
||||||
|
seasonStartMonth: 3,
|
||||||
|
seasonStartDay: 15,
|
||||||
|
seasonEndMonth: 10,
|
||||||
|
seasonEndDay: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Init helpers ────────────────────────────────────────────────────────────
|
||||||
|
function initSeasonStart(): IrrigationSeasonSettings {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_SEASON;
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("wl_season_settings:v1");
|
||||||
|
return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SEASON;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRecipientName(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return localStorage.getItem("wl_recipient_name") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initState(props: {
|
||||||
|
initialHeadgates: AdminHeadgate[];
|
||||||
|
initialUsers: AdminIrrigator[];
|
||||||
|
initialEntries: AdminEntry[];
|
||||||
|
}): State {
|
||||||
|
return {
|
||||||
|
headgates: props.initialHeadgates,
|
||||||
|
users: props.initialUsers,
|
||||||
|
entries: props.initialEntries,
|
||||||
|
toast: null,
|
||||||
|
forms: {
|
||||||
|
showAddHg: false,
|
||||||
|
newHg: { name: "", unit: "CFS", notes: "" },
|
||||||
|
savingHg: false,
|
||||||
|
showAddUser: false,
|
||||||
|
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
|
||||||
|
savingUser: false,
|
||||||
|
newPin: null,
|
||||||
|
resetPin: null,
|
||||||
|
},
|
||||||
|
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
|
||||||
|
csvLoading: false,
|
||||||
|
today: "",
|
||||||
|
todayLabel: "",
|
||||||
|
showReportSettings: false,
|
||||||
|
seasonStart: initSeasonStart(),
|
||||||
|
recipientName: initRecipientName(),
|
||||||
|
reportPreview: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reducer ─────────────────────────────────────────────────────────────────
|
||||||
|
export function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
// Headgates
|
||||||
|
case "ADD_HEADGATE":
|
||||||
|
return { ...state, headgates: [action.headgate, ...state.headgates] };
|
||||||
|
case "REMOVE_HEADGATE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
headgates: state.headgates.filter((h) => h.id !== action.id),
|
||||||
|
};
|
||||||
|
case "UPDATE_HEADGATE_TOKEN":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
headgates: state.headgates.map((h) =>
|
||||||
|
h.id === action.id ? { ...h, headgate_token: action.token } : h,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// Users
|
||||||
|
case "ADD_USER":
|
||||||
|
return { ...state, users: [action.user, ...state.users] };
|
||||||
|
case "DEACTIVATE_USER":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
users: state.users.map((u) =>
|
||||||
|
u.id === action.id ? { ...u, active: false } : u,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// Toast
|
||||||
|
case "SET_TOAST":
|
||||||
|
return { ...state, toast: action.toast };
|
||||||
|
case "CLEAR_TOAST":
|
||||||
|
return { ...state, toast: null };
|
||||||
|
// Forms - Headgate
|
||||||
|
case "TOGGLE_ADD_HG":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, showAddHg: !state.forms.showAddHg },
|
||||||
|
};
|
||||||
|
case "SET_NEW_HG_NAME":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newHg: { ...state.forms.newHg, name: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_NEW_HG_UNIT":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newHg: { ...state.forms.newHg, unit: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_NEW_HG_NOTES":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newHg: { ...state.forms.newHg, notes: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "RESET_NEW_HG":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newHg: { name: "", unit: "CFS", notes: "" },
|
||||||
|
showAddHg: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_SAVING_HG":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, savingHg: action.value },
|
||||||
|
};
|
||||||
|
// Forms - User
|
||||||
|
case "TOGGLE_ADD_USER":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, showAddUser: !state.forms.showAddUser },
|
||||||
|
};
|
||||||
|
case "SET_NEW_USER_NAME":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newUser: { ...state.forms.newUser, name: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_NEW_USER_ROLE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newUser: { ...state.forms.newUser, role: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_NEW_USER_LANG":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newUser: { ...state.forms.newUser, lang: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_NEW_USER_PHONE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newUser: { ...state.forms.newUser, phone: action.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "RESET_NEW_USER":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
|
||||||
|
showAddUser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_SAVING_USER":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, savingUser: action.value },
|
||||||
|
};
|
||||||
|
case "SET_NEW_PIN":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, newPin: action.pin },
|
||||||
|
};
|
||||||
|
case "CLEAR_NEW_PIN":
|
||||||
|
return { ...state, forms: { ...state.forms, newPin: null } };
|
||||||
|
case "SET_RESET_PIN":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forms: { ...state.forms, resetPin: action.pin },
|
||||||
|
};
|
||||||
|
case "CLEAR_RESET_PIN":
|
||||||
|
return { ...state, forms: { ...state.forms, resetPin: null } };
|
||||||
|
// Filters
|
||||||
|
case "SET_FILTER_DATE_FROM":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { ...state.filters, dateFrom: action.value },
|
||||||
|
};
|
||||||
|
case "SET_FILTER_DATE_TO":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { ...state.filters, dateTo: action.value },
|
||||||
|
};
|
||||||
|
case "SET_FILTER_HEADGATE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { ...state.filters, headgate: action.value },
|
||||||
|
};
|
||||||
|
case "SET_FILTER_USER":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { ...state.filters, user: action.value },
|
||||||
|
};
|
||||||
|
case "SET_FILTER_VIA":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { ...state.filters, via: action.value },
|
||||||
|
};
|
||||||
|
case "CLEAR_FILTERS":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
|
||||||
|
};
|
||||||
|
// Misc UI
|
||||||
|
case "SET_CSV_LOADING":
|
||||||
|
return { ...state, csvLoading: action.value };
|
||||||
|
case "SET_TODAY":
|
||||||
|
return { ...state, today: action.today, todayLabel: action.label };
|
||||||
|
case "TOGGLE_REPORT_SETTINGS":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showReportSettings: !state.showReportSettings,
|
||||||
|
};
|
||||||
|
case "SET_SEASON_START":
|
||||||
|
return { ...state, seasonStart: action.value };
|
||||||
|
case "SET_RECIPIENT_NAME":
|
||||||
|
return { ...state, recipientName: action.value };
|
||||||
|
case "OPEN_REPORT_PREVIEW":
|
||||||
|
return { ...state, reportPreview: action.preview };
|
||||||
|
case "CLOSE_REPORT_PREVIEW":
|
||||||
|
return { ...state, reportPreview: null };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selectors ────────────────────────────────────────────────────────────────
|
||||||
|
/** Latest entry per headgate (by logged_at), for the headgate cards. */
|
||||||
|
export function lastEntryByHeadgateSelector(entries: AdminEntry[]) {
|
||||||
|
const map = new Map<string, AdminEntry>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const existing = map.get(e.headgate_id);
|
||||||
|
if (!existing || e.logged_at > existing.logged_at) {
|
||||||
|
map.set(e.headgate_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the current filter set to the entry list. */
|
||||||
|
export function filteredEntriesSelector(
|
||||||
|
entries: AdminEntry[],
|
||||||
|
filters: State["filters"],
|
||||||
|
) {
|
||||||
|
return entries.filter((e) => {
|
||||||
|
if (filters.dateFrom && e.logged_at < filters.dateFrom) return false;
|
||||||
|
if (filters.dateTo && e.logged_at > filters.dateTo + "T23:59:59.999Z")
|
||||||
|
return false;
|
||||||
|
if (filters.headgate && e.headgate_id !== filters.headgate) return false;
|
||||||
|
if (filters.user && e.user_id !== filters.user) return false;
|
||||||
|
if (filters.via && e.submitted_via !== filters.via) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's entries, by logged_date (with logged_at fallback). */
|
||||||
|
export function todayEntriesSelector(entries: AdminEntry[], today: string) {
|
||||||
|
if (!today) return [];
|
||||||
|
return entries.filter(
|
||||||
|
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* HeaderNav — top-of-page quick links for the Water Log admin.
|
||||||
|
*
|
||||||
|
* Renders the three contextual exits (QR Codes, Settings, Field Admin)
|
||||||
|
* as secondary `AdminButton`s. Lives outside the tab shell so it's
|
||||||
|
* always visible regardless of which tab the user is on.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import AdminButton from "@/components/admin/design-system/AdminButton";
|
||||||
|
import { QrIcon, GearIcon, FieldIcon } from "./icons";
|
||||||
|
|
||||||
|
export function HeaderNav() {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
<Link href="/admin/water-log/headgates">
|
||||||
|
<AdminButton variant="secondary" size="sm" icon={<QrIcon />}>
|
||||||
|
QR Codes
|
||||||
|
</AdminButton>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/water-log/settings">
|
||||||
|
<AdminButton variant="secondary" size="sm" icon={<GearIcon />}>
|
||||||
|
Settings
|
||||||
|
</AdminButton>
|
||||||
|
</Link>
|
||||||
|
<Link href="/water/admin" target="_blank" rel="noopener noreferrer">
|
||||||
|
<AdminButton variant="secondary" size="sm" icon={<FieldIcon />}>
|
||||||
|
Field Admin ↗
|
||||||
|
</AdminButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* Shared primitives for the Water Log admin module.
|
||||||
|
*
|
||||||
|
* These are the small reusable sub-components used by the tabs:
|
||||||
|
* section headers, stat tiles, status pills, form inputs, the PIN
|
||||||
|
* banner, the role legend, and the empty state. All use the "Field
|
||||||
|
* Almanac" palette (cream / forest / clay) so they sit naturally
|
||||||
|
* together.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { KeyIcon } from "./icons";
|
||||||
|
|
||||||
|
// ── SectionHeader ────────────────────────────────────────────────────────────
|
||||||
|
export function SectionHeader({
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
index: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="mb-3 mt-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] pb-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#1a4d2e]">
|
||||||
|
{index}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="mt-0.5 text-xl font-semibold text-[#1d1d1f]"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 max-w-xl text-xs text-[#57694e]">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StatTile ────────────────────────────────────────────────────────────────
|
||||||
|
export function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
accent,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unit: string;
|
||||||
|
accent?: boolean;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
accent ? "border-[#1a4d2e]/30 bg-[#f0fdf4]" : "border-[#d4d9d3] bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-wider text-[#57694e]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className={`text-lg font-semibold text-[#1a4d2e] ${
|
||||||
|
mono ? "font-mono" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit && (
|
||||||
|
<span className="font-mono text-[10px] uppercase text-[#57694e]">
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StatusPill ──────────────────────────────────────────────────────────────
|
||||||
|
const statusPillMap = {
|
||||||
|
active: { bg: "bg-[#dcfce7]", fg: "text-[#15803d]", label: "Active" },
|
||||||
|
inactive: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Inactive" },
|
||||||
|
closed: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Closed" },
|
||||||
|
warn: { bg: "bg-[#fef9c3]", fg: "text-[#a16207]", label: "Maint." },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function StatusPill({
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
kind: "active" | "inactive" | "closed" | "warn";
|
||||||
|
}) {
|
||||||
|
const s = statusPillMap[kind];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${s.bg} ${s.fg}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PinBanner ───────────────────────────────────────────────────────────────
|
||||||
|
export function PinBanner({
|
||||||
|
title,
|
||||||
|
pin,
|
||||||
|
tone = "ok",
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
pin: string;
|
||||||
|
tone?: "ok" | "warn";
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-4 flex items-center gap-4 rounded-xl border p-4 ${
|
||||||
|
tone === "warn"
|
||||||
|
? "border-[#fde047] bg-[#fef9c3]"
|
||||||
|
: "border-[#15803d]/40 bg-[#dcfce7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyIcon />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="font-mono text-2xl font-bold tracking-[0.5em] text-[#1a4d2e]"
|
||||||
|
aria-label={`PIN ${pin.split("").join(" ")}`}
|
||||||
|
>
|
||||||
|
{pin}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RoleLegend ──────────────────────────────────────────────────────────────
|
||||||
|
export function RoleLegend() {
|
||||||
|
return (
|
||||||
|
<p className="mb-3 flex items-center gap-3 font-mono text-[11px] text-[#57694e]">
|
||||||
|
<span>
|
||||||
|
<span className="font-bold text-[#1e3a8a]">ADMIN</span>
|
||||||
|
<span className="text-[#86868b]"> — manage headgates, users, entries</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[#d4d9d3]">·</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-bold text-[#15803d]">IRRIGATOR</span>
|
||||||
|
<span className="text-[#86868b]"> — submit entries only</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EmptyState ──────────────────────────────────────────────────────────────
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#d4d9d3] bg-white px-6 py-10 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex w-12 items-center justify-center text-[#86868b]">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-base font-semibold text-[#1d1d1f]"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="mx-auto mt-1 max-w-md text-sm text-[#57694e]">{body}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input ───────────────────────────────────────────────────────────────────
|
||||||
|
export function Input({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
required,
|
||||||
|
type = "text",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Select ──────────────────────────────────────────────────────────────────
|
||||||
|
export function Select({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: (string | { value: string; label: string })[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
|
||||||
|
>
|
||||||
|
{options.map((o) => {
|
||||||
|
if (typeof o === "string") {
|
||||||
|
return (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FilterChip ──────────────────────────────────────────────────────────────
|
||||||
|
export function FilterChip({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg border border-[#d4d9d3] bg-[#fafaf7] px-2 py-1">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-wider text-[#86868b]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avatar ──────────────────────────────────────────────────────────────────
|
||||||
|
export function Avatar({ name, role }: { name: string; role: string }) {
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.flatMap((n) => (n[0] ? [n[0]] : []))
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
const bg = role === "water_admin" ? "#1a4d2e" : "#57694e";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-bold text-white"
|
||||||
|
style={{ background: bg }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{initials || "·"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MonthSelect / DayInput ──────────────────────────────────────────────────
|
||||||
|
export function MonthSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (m: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className="flex-1 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
|
||||||
|
aria-label="Month"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<option key={i + 1} value={i + 1}>
|
||||||
|
{new Date(0, i).toLocaleString("en", { month: "short" })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (d: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10) || 1)}
|
||||||
|
className="w-16 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
|
||||||
|
aria-label="Day"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Th / Td (admin-table cells, used by the ReportPreviewModal) ─────────────
|
||||||
|
export function Th({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)] ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Td({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
colSpan,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
colSpan?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
colSpan={colSpan}
|
||||||
|
className={`px-3 py-2 text-sm text-[var(--admin-text-primary)] ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* ReportPreviewModal — preview of the daily water report that would be
|
||||||
|
* sent by the cron. Built on the shared `GlassModal` so ESC, focus
|
||||||
|
* trapping, and scroll-lock match the rest of /admin.
|
||||||
|
*
|
||||||
|
* Triggered from the Today tab (or anywhere that needs to show the
|
||||||
|
* daily report payload); the preview state lives in the shell reducer.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
import { formatDailyWaterReport } from "@/lib/water-log-reporting";
|
||||||
|
import type { NotifyFn, ReportPreviewData } from "../types";
|
||||||
|
import { Th, Td } from "./Primitives";
|
||||||
|
|
||||||
|
export function ReportPreviewModal({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
notify,
|
||||||
|
}: {
|
||||||
|
data: ReportPreviewData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
notify: NotifyFn;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data;
|
||||||
|
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
const text = formatDailyWaterReport(rows, {
|
||||||
|
recipientName: recipientName,
|
||||||
|
previewMode: true,
|
||||||
|
});
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
notify("Report copied to clipboard");
|
||||||
|
setTimeout(() => setCopied(false), 1600);
|
||||||
|
} catch {
|
||||||
|
notify("Copy failed — your browser blocked clipboard access", "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
`${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` +
|
||||||
|
`${total.toFixed(2)} ${totalUnit} total · ` +
|
||||||
|
(inSeason ? "in season" : "out of season");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title="Daily water report — preview"
|
||||||
|
subtitle={subtitle}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Meta line */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-[var(--admin-text-secondary)]">
|
||||||
|
Date:
|
||||||
|
</span>{" "}
|
||||||
|
{dateLabel}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
|
||||||
|
<span className="font-mono text-[var(--admin-text-secondary)]">
|
||||||
|
{dateIso}
|
||||||
|
</span>
|
||||||
|
{recipientName ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline text-[var(--admin-border)]">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-[var(--admin-text-secondary)]">
|
||||||
|
Recipient:
|
||||||
|
</span>{" "}
|
||||||
|
{recipientName}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview banner — keep it clear this hasn't been sent */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg px-3 py-2 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--admin-warning-bg, #fef9c3)",
|
||||||
|
color: "var(--admin-warning-text, #a16207)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview only. No message has been sent. Daily-report delivery is
|
||||||
|
handled by the scheduled{" "}
|
||||||
|
<code className="font-mono">api/cron/water-report</code> endpoint.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)]">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
|
||||||
|
<Th className="w-[72px]">Time</Th>
|
||||||
|
<Th>Headgate</Th>
|
||||||
|
<Th>Irrigator</Th>
|
||||||
|
<Th className="w-[88px] text-right">Volume</Th>
|
||||||
|
<Th>Note</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const time = new Date(row.logged_at).toLocaleTimeString(
|
||||||
|
"en-US",
|
||||||
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="border-t border-[var(--admin-border-light)]"
|
||||||
|
>
|
||||||
|
<Td className="font-mono text-[var(--admin-text-secondary)]">
|
||||||
|
{time}
|
||||||
|
</Td>
|
||||||
|
<Td className="font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{row.headgate_name}
|
||||||
|
</Td>
|
||||||
|
<Td className="text-[var(--admin-text-secondary)]">
|
||||||
|
{row.user_name}
|
||||||
|
</Td>
|
||||||
|
<Td className="text-right">
|
||||||
|
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">
|
||||||
|
{row.measurement.toFixed(2)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{row.unit}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
<Td className="text-[var(--admin-text-muted)]">
|
||||||
|
{row.notes ?? <span className="opacity-40">—</span>}
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr
|
||||||
|
className="border-t-2"
|
||||||
|
style={{ borderColor: "var(--admin-border)" }}
|
||||||
|
>
|
||||||
|
<Td
|
||||||
|
className="!border-0 pt-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]"
|
||||||
|
colSpan={3}
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Td>
|
||||||
|
<Td className="!border-0 pt-2 text-right">
|
||||||
|
<span className="font-mono font-bold text-[var(--admin-text-primary)]">
|
||||||
|
{total.toFixed(2)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{totalUnit}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
<Td className="!border-0 pt-2">
|
||||||
|
<span className="opacity-40">—</span>
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col-reverse items-stretch gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy plain text"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ToastNotification — bottom-right toast surface used by every tab.
|
||||||
|
*
|
||||||
|
* State lives in the parent reducer; this component only renders.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Toast as ToastT } from "../types";
|
||||||
|
|
||||||
|
export function ToastNotification({ toast }: { toast: ToastT | null }) {
|
||||||
|
if (!toast) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed bottom-6 right-6 z-50 rounded-xl px-4 py-3 font-medium shadow-lg transition-all ${
|
||||||
|
toast.kind === "ok"
|
||||||
|
? "border border-[#15803d] bg-[#dcfce7] text-[#15803d]"
|
||||||
|
: "border border-[#b91c1c] bg-[#fee2e2] text-[#b91c1c]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Inline icons used by the Water Log admin module.
|
||||||
|
*
|
||||||
|
* All accept `currentColor` (or an explicit stroke color for the key
|
||||||
|
* icon, which always renders forest green for contrast against the
|
||||||
|
* cream "Field Almanac" palette).
|
||||||
|
*
|
||||||
|
* Kept here so each tab can import what it needs without dragging
|
||||||
|
* in the whole panel.
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function QrIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="5" height="5" />
|
||||||
|
<rect x="9" y="2" width="5" height="5" />
|
||||||
|
<rect x="2" y="9" width="5" height="5" />
|
||||||
|
<path d="M9 9 H11 V11 H9 Z" />
|
||||||
|
<path d="M12 9 H14 V12" />
|
||||||
|
<path d="M9 12 V14 H14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.2" />
|
||||||
|
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<path d="M2 11 L8 5 L11 8 L14 5" />
|
||||||
|
<path d="M2 14 H14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<rect x="3" y="2" width="10" height="12" rx="1" />
|
||||||
|
<path d="M5 5 H11 M5 8 H11 M5 11 H8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<path d="M8 2 V10 M5 7 L8 10 L11 7" />
|
||||||
|
<path d="M2 12 V14 H14 V12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1a4d2e"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="12" r="4" />
|
||||||
|
<path d="M12 12 H22 M18 12 V16 M22 12 V15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4 21 C4 16 8 14 12 14 C16 14 20 16 20 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
|
<path d="M3 10 H21 M9 4 V20" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="6" />
|
||||||
|
<path d="M8 4 V8 L11 10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* HeadgatesTab — CRUD surface for water headgates.
|
||||||
|
*
|
||||||
|
* Lists every headgate in a card grid, with add/regenerate-token/delete
|
||||||
|
* controls. Owns its own form state via the shell reducer.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import AdminButton from "@/components/admin/design-system/AdminButton";
|
||||||
|
import {
|
||||||
|
createWaterHeadgate,
|
||||||
|
deleteWaterHeadgate,
|
||||||
|
regenerateHeadgateToken,
|
||||||
|
type AdminEntry,
|
||||||
|
type AdminHeadgate,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import { WaterGauge } from "@/components/water/icons";
|
||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
Input,
|
||||||
|
SectionHeader,
|
||||||
|
Select,
|
||||||
|
StatusPill,
|
||||||
|
} from "../shared/Primitives";
|
||||||
|
import type { Action, NotifyFn } from "../types";
|
||||||
|
|
||||||
|
export type HeadgatesTabProps = {
|
||||||
|
brandId: string;
|
||||||
|
headgates: AdminHeadgate[];
|
||||||
|
lastEntryByHeadgate: Map<string, AdminEntry>;
|
||||||
|
canManage: boolean;
|
||||||
|
showAddHg: boolean;
|
||||||
|
newHg: { name: string; unit: string; notes: string };
|
||||||
|
savingHg: boolean;
|
||||||
|
notify: NotifyFn;
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
onEdit: (h: AdminHeadgate) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeadgatesTab({
|
||||||
|
brandId,
|
||||||
|
headgates,
|
||||||
|
lastEntryByHeadgate,
|
||||||
|
canManage,
|
||||||
|
showAddHg,
|
||||||
|
newHg,
|
||||||
|
savingHg,
|
||||||
|
notify,
|
||||||
|
dispatch,
|
||||||
|
onEdit,
|
||||||
|
}: HeadgatesTabProps) {
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmedName = newHg.name.trim();
|
||||||
|
if (!trimmedName) return;
|
||||||
|
dispatch({ type: "SET_SAVING_HG", value: true });
|
||||||
|
const res = await createWaterHeadgate(brandId, trimmedName, newHg.unit, {
|
||||||
|
notes: newHg.notes.trim() || null,
|
||||||
|
});
|
||||||
|
if (res.success && res.headgate) {
|
||||||
|
dispatch({ type: "ADD_HEADGATE", headgate: res.headgate });
|
||||||
|
dispatch({ type: "RESET_NEW_HG" });
|
||||||
|
notify("Headgate added");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
dispatch({ type: "SET_SAVING_HG", value: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(h: AdminHeadgate) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Delete "${h.name}"? Existing log entries will be preserved.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const res = await deleteWaterHeadgate(h.id);
|
||||||
|
if (res.success) {
|
||||||
|
dispatch({ type: "REMOVE_HEADGATE", id: h.id });
|
||||||
|
notify("Headgate removed");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerateToken(h: AdminHeadgate) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Regenerate QR token for "${h.name}"? Existing printed QR codes will stop working.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const res = await regenerateHeadgateToken(h.id);
|
||||||
|
if (res.success && res.token) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_HEADGATE_TOKEN",
|
||||||
|
id: h.id,
|
||||||
|
token: res.token,
|
||||||
|
});
|
||||||
|
notify("Token regenerated");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
index="§ 01"
|
||||||
|
title="Headgates"
|
||||||
|
subtitle="Physical gates in the ditch network. Each gets a printable QR code so field workers can scan to log a reading without typing the gate name."
|
||||||
|
action={
|
||||||
|
canManage ? (
|
||||||
|
<AdminButton size="sm" onClick={() => dispatch({ type: "TOGGLE_ADD_HG" })}>
|
||||||
|
{showAddHg ? "Cancel" : "+ Add Headgate"}
|
||||||
|
</AdminButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showAddHg && canManage && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_120px_1fr_auto]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={newHg.name}
|
||||||
|
onChange={(v) => dispatch({ type: "SET_NEW_HG_NAME", value: v })}
|
||||||
|
placeholder="e.g. North Field Gate 1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Unit"
|
||||||
|
value={newHg.unit}
|
||||||
|
onChange={(v) => dispatch({ type: "SET_NEW_HG_UNIT", value: v })}
|
||||||
|
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Notes"
|
||||||
|
value={newHg.notes}
|
||||||
|
onChange={(v) => dispatch({ type: "SET_NEW_HG_NOTES", value: v })}
|
||||||
|
placeholder="Location, GPS, contact…"
|
||||||
|
/>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<AdminButton
|
||||||
|
type="submit"
|
||||||
|
disabled={savingHg}
|
||||||
|
isLoading={savingHg}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{headgates.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<WaterGauge size={36} level={null} status="open" />}
|
||||||
|
title="No headgates yet"
|
||||||
|
body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{headgates.map((h) => {
|
||||||
|
const last = lastEntryByHeadgate.get(h.id);
|
||||||
|
const level =
|
||||||
|
last && h.high_threshold
|
||||||
|
? Math.min(1, last.measurement / Number(h.high_threshold))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={h.id}
|
||||||
|
className="group rounded-xl border border-[#d4d9d3] bg-white p-4 transition hover:border-[#1a4d2e]/40 hover:shadow-[0_4px_16px_rgba(26,77,46,0.08)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<WaterGauge
|
||||||
|
size={36}
|
||||||
|
level={level}
|
||||||
|
status={h.status as "open" | "closed" | "maintenance"}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="truncate font-semibold text-[#1d1d1f]">
|
||||||
|
{h.name}
|
||||||
|
</h3>
|
||||||
|
<StatusPill
|
||||||
|
kind={
|
||||||
|
!h.active
|
||||||
|
? "inactive"
|
||||||
|
: h.status === "closed"
|
||||||
|
? "closed"
|
||||||
|
: h.status === "maintenance"
|
||||||
|
? "warn"
|
||||||
|
: "active"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 font-mono text-[11px] uppercase tracking-wider text-[#86868b]">
|
||||||
|
Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}…
|
||||||
|
</p>
|
||||||
|
{last ? (
|
||||||
|
<p className="mt-2 text-sm text-[#57694e]">
|
||||||
|
Last:{" "}
|
||||||
|
<span className="font-mono font-semibold text-[#1d1d1f]">
|
||||||
|
{last.measurement} {h.unit}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-[#86868b]">
|
||||||
|
by {last.user_name} · {formatDateTime(last.logged_at)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm italic text-[#86868b]">
|
||||||
|
No readings yet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(h.high_threshold != null || h.low_threshold != null) && (
|
||||||
|
<p className="mt-1 font-mono text-[11px] text-[#57694e]">
|
||||||
|
{h.high_threshold != null && (
|
||||||
|
<>Hi ≥ {h.high_threshold} </>
|
||||||
|
)}
|
||||||
|
{h.low_threshold != null && (
|
||||||
|
<>Lo ≤ {h.low_threshold}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(h)}
|
||||||
|
className="font-medium text-[#1a4d2e] hover:underline"
|
||||||
|
aria-label="Edit →"
|
||||||
|
>
|
||||||
|
Edit →
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRegenerateToken(h)}
|
||||||
|
className="text-[#57694e] hover:text-[#1a4d2e]"
|
||||||
|
aria-label="Rotate token"
|
||||||
|
>
|
||||||
|
Rotate token
|
||||||
|
</button>
|
||||||
|
<span className="text-[#d4d9d3]">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(h)}
|
||||||
|
className="text-[#b91c1c] hover:text-[#7f1d1d]"
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking tab — embeds the existing TimeTrackingAdminPanel
|
||||||
|
* (which already has its own sub-tabs: Summary / Workers / Tasks /
|
||||||
|
* Logs / Settings) inside the Water Log shell.
|
||||||
|
*
|
||||||
|
* Cycle 3 — this is the customer "hub" surface the water log now
|
||||||
|
* offers: one place to see today's entries, the headgates, who can
|
||||||
|
* log them, AND how many hours those workers put in.
|
||||||
|
*/
|
||||||
|
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||||
|
|
||||||
|
export type TimeTrackingTabProps = {
|
||||||
|
brandId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeTrackingTab({ brandId }: TimeTrackingTabProps) {
|
||||||
|
return <TimeTrackingAdminPanel brandId={brandId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
/**
|
||||||
|
* TodayTab — the "Today" surface for the Water Log admin.
|
||||||
|
*
|
||||||
|
* Combines the hero gauge + summary stats with the recent entries
|
||||||
|
* table + filter bar. Holds the entry CRUD, CSV export, and
|
||||||
|
* "Generate Report" trigger.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import AdminButton from "@/components/admin/design-system/AdminButton";
|
||||||
|
import { WaterGauge, SeasonMark } from "@/components/water/icons";
|
||||||
|
import {
|
||||||
|
getWaterEntries,
|
||||||
|
type AdminEntry,
|
||||||
|
type AdminHeadgate,
|
||||||
|
type AdminIrrigator,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import {
|
||||||
|
downloadWaterLogCSV,
|
||||||
|
filterWaterLogEntries,
|
||||||
|
shapeWaterLogEntry,
|
||||||
|
type IrrigationSeasonSettings,
|
||||||
|
type WaterLogReportRow,
|
||||||
|
} from "@/lib/water-log-reporting";
|
||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
DayInput,
|
||||||
|
EmptyState,
|
||||||
|
FilterChip,
|
||||||
|
MonthSelect,
|
||||||
|
SectionHeader,
|
||||||
|
StatTile,
|
||||||
|
} from "../shared/Primitives";
|
||||||
|
import { DownloadIcon, ReportIcon, TableIcon } from "../shared/icons";
|
||||||
|
import type { Action, NotifyFn, State } from "../types";
|
||||||
|
|
||||||
|
export type TodayTabProps = {
|
||||||
|
brandId: string;
|
||||||
|
state: State;
|
||||||
|
todayLabel: string;
|
||||||
|
todayEntries: AdminEntry[];
|
||||||
|
todayTotal: number;
|
||||||
|
inSeason: boolean;
|
||||||
|
notify: NotifyFn;
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
onPreviewReport: () => void;
|
||||||
|
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||||
|
onRecipientNameChange: (v: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TodayTab({
|
||||||
|
brandId,
|
||||||
|
state,
|
||||||
|
todayLabel,
|
||||||
|
todayEntries,
|
||||||
|
todayTotal,
|
||||||
|
inSeason,
|
||||||
|
notify,
|
||||||
|
dispatch,
|
||||||
|
onPreviewReport,
|
||||||
|
onSeasonChange,
|
||||||
|
onRecipientNameChange,
|
||||||
|
}: TodayTabProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleExportCSV() {
|
||||||
|
dispatch({ type: "SET_CSV_LOADING", value: true });
|
||||||
|
try {
|
||||||
|
const all = await getWaterEntries(brandId, 5000);
|
||||||
|
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
|
||||||
|
const exportFilters = {
|
||||||
|
dateFrom: state.filters.dateFrom || undefined,
|
||||||
|
dateTo: state.filters.dateTo || undefined,
|
||||||
|
headgateId: state.filters.headgate || undefined,
|
||||||
|
userId: state.filters.user || undefined,
|
||||||
|
submittedVia: state.filters.via || undefined,
|
||||||
|
};
|
||||||
|
const filtered = filterWaterLogEntries(shaped, exportFilters);
|
||||||
|
downloadWaterLogCSV(`water-log-${state.today}.csv`, filtered);
|
||||||
|
notify(`Exported ${filtered.length} entries`);
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_CSV_LOADING", value: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TodaySummary
|
||||||
|
todayLabel={todayLabel}
|
||||||
|
todayEntries={todayEntries}
|
||||||
|
todayTotal={todayTotal}
|
||||||
|
headgates={state.headgates}
|
||||||
|
users={state.users}
|
||||||
|
inSeason={inSeason}
|
||||||
|
seasonStart={state.seasonStart}
|
||||||
|
recipientName={state.recipientName}
|
||||||
|
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
|
||||||
|
onSeasonChange={onSeasonChange}
|
||||||
|
onRecipientNameChange={onRecipientNameChange}
|
||||||
|
onPreviewReport={onPreviewReport}
|
||||||
|
showReportSettings={state.showReportSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecentEntriesSection
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
onEditEntry={(e) => router.push(`/admin/water-log/entries/${e.id}`)}
|
||||||
|
onExportCSV={handleExportCSV}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TodaySummary (hero gauge + stats + report settings toggle) ──────────────
|
||||||
|
function TodaySummary({
|
||||||
|
todayLabel,
|
||||||
|
todayEntries,
|
||||||
|
todayTotal,
|
||||||
|
headgates,
|
||||||
|
users,
|
||||||
|
inSeason,
|
||||||
|
showReportSettings,
|
||||||
|
seasonStart,
|
||||||
|
recipientName,
|
||||||
|
onToggleReportSettings,
|
||||||
|
onSeasonChange,
|
||||||
|
onRecipientNameChange,
|
||||||
|
onPreviewReport,
|
||||||
|
}: {
|
||||||
|
todayLabel: string;
|
||||||
|
todayEntries: AdminEntry[];
|
||||||
|
todayTotal: number;
|
||||||
|
headgates: AdminHeadgate[];
|
||||||
|
users: AdminIrrigator[];
|
||||||
|
inSeason: boolean;
|
||||||
|
showReportSettings: boolean;
|
||||||
|
seasonStart: IrrigationSeasonSettings;
|
||||||
|
recipientName: string;
|
||||||
|
onToggleReportSettings: () => void;
|
||||||
|
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||||
|
onRecipientNameChange: (v: string) => void;
|
||||||
|
onPreviewReport: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.18]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
|
||||||
|
{/* Hero gauge */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
|
||||||
|
Tuxedo Ditch Co.
|
||||||
|
</div>
|
||||||
|
<WaterGauge
|
||||||
|
size={120}
|
||||||
|
level={
|
||||||
|
todayEntries.length === 0
|
||||||
|
? null
|
||||||
|
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
|
||||||
|
}
|
||||||
|
status="open"
|
||||||
|
ariaLabel="Today's water level"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
|
||||||
|
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
Today's Summary
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#57694e]">
|
||||||
|
<SeasonMark inSeason={inSeason} size={14} />
|
||||||
|
{inSeason ? (
|
||||||
|
<span className="font-medium text-[#1a4d2e]">
|
||||||
|
In irrigation season
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-[#a16207]">
|
||||||
|
Outside irrigation season
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[#57694e]/60">·</span>
|
||||||
|
<span className="font-mono text-[#1d1d1f]">
|
||||||
|
{todayEntries.length}{" "}
|
||||||
|
{todayEntries.length === 1 ? "entry" : "entries"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#57694e]/60">·</span>
|
||||||
|
<span className="text-[#57694e]">{todayLabel}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatTile
|
||||||
|
label="Total volume"
|
||||||
|
value={todayTotal.toFixed(2)}
|
||||||
|
unit={headgates[0]?.unit ?? "CFS"}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Headgates"
|
||||||
|
value={headgates.filter((h) => h.active).length.toString()}
|
||||||
|
unit={`of ${headgates.length}`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Active irrigators"
|
||||||
|
value={users.filter((u) => u.active).length.toString()}
|
||||||
|
unit={`of ${users.length}`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Last entry"
|
||||||
|
value={
|
||||||
|
todayEntries.length
|
||||||
|
? formatDateTime(todayEntries[0].logged_at)
|
||||||
|
.split(",")
|
||||||
|
.pop()
|
||||||
|
?.trim() ?? "—"
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
unit=""
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<AdminButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ReportIcon />}
|
||||||
|
onClick={onPreviewReport}
|
||||||
|
>
|
||||||
|
Preview Report
|
||||||
|
</AdminButton>
|
||||||
|
<AdminButton
|
||||||
|
variant={showReportSettings ? "primary" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.2" />
|
||||||
|
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
onClick={onToggleReportSettings}
|
||||||
|
>
|
||||||
|
Report Settings
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReportSettings && (
|
||||||
|
<ReportSettingsInline
|
||||||
|
season={seasonStart}
|
||||||
|
onSeasonChange={onSeasonChange}
|
||||||
|
recipientName={recipientName}
|
||||||
|
onRecipientNameChange={onRecipientNameChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline Report Settings (kept under the toggle button) ───────────────────
|
||||||
|
function ReportSettingsInline({
|
||||||
|
season,
|
||||||
|
onSeasonChange,
|
||||||
|
recipientName,
|
||||||
|
onRecipientNameChange,
|
||||||
|
}: {
|
||||||
|
season: IrrigationSeasonSettings;
|
||||||
|
onSeasonChange: (s: IrrigationSeasonSettings) => void;
|
||||||
|
recipientName: string;
|
||||||
|
onRecipientNameChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
|
||||||
|
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
|
||||||
|
<strong>Preview only.</strong> Daily-report delivery is wired through
|
||||||
|
the scheduled <code>api/cron/water-report</code> endpoint and respects
|
||||||
|
these settings.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ReportSeasonRow
|
||||||
|
label="Season start"
|
||||||
|
month={season.seasonStartMonth}
|
||||||
|
day={season.seasonStartDay}
|
||||||
|
onMonthChange={(m) =>
|
||||||
|
onSeasonChange({ ...season, seasonStartMonth: m })
|
||||||
|
}
|
||||||
|
onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
|
||||||
|
/>
|
||||||
|
<ReportSeasonRow
|
||||||
|
label="Season end"
|
||||||
|
month={season.seasonEndMonth}
|
||||||
|
day={season.seasonEndDay}
|
||||||
|
onMonthChange={(m) =>
|
||||||
|
onSeasonChange({ ...season, seasonEndMonth: m })
|
||||||
|
}
|
||||||
|
onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||||
|
Recipient name (used in report salutation)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipientName}
|
||||||
|
onChange={(e) => onRecipientNameChange(e.target.value)}
|
||||||
|
placeholder="David"
|
||||||
|
aria-label="Recipient name"
|
||||||
|
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSeasonRow({
|
||||||
|
label,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
onMonthChange,
|
||||||
|
onDayChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
onMonthChange: (m: number) => void;
|
||||||
|
onDayChange: (d: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<MonthSelect value={month} onChange={onMonthChange} />
|
||||||
|
<DayInput value={day} onChange={onDayChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecentEntriesSection (table + filters) ──────────────────────────────────
|
||||||
|
function RecentEntriesSection({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
onEditEntry,
|
||||||
|
onExportCSV,
|
||||||
|
}: {
|
||||||
|
state: State;
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
onEditEntry: (e: AdminEntry) => void;
|
||||||
|
onExportCSV: () => void;
|
||||||
|
}) {
|
||||||
|
const filteredEntries = state.entries.filter((e) => {
|
||||||
|
if (state.filters.dateFrom && e.logged_at < state.filters.dateFrom)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
state.filters.dateTo &&
|
||||||
|
e.logged_at > state.filters.dateTo + "T23:59:59.999Z"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (state.filters.headgate && e.headgate_id !== state.filters.headgate)
|
||||||
|
return false;
|
||||||
|
if (state.filters.user && e.user_id !== state.filters.user) return false;
|
||||||
|
if (state.filters.via && e.submitted_via !== state.filters.via)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
index="§ 03"
|
||||||
|
title="Recent Entries"
|
||||||
|
subtitle="The latest readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
|
||||||
|
action={
|
||||||
|
<AdminButton
|
||||||
|
size="sm"
|
||||||
|
onClick={onExportCSV}
|
||||||
|
isLoading={state.csvLoading}
|
||||||
|
icon={<DownloadIcon />}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</AdminButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
|
||||||
|
<FilterChip label="From">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={state.filters.dateFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_FILTER_DATE_FROM", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||||
|
aria-label="Filter: from date"
|
||||||
|
/>
|
||||||
|
</FilterChip>
|
||||||
|
<FilterChip label="To">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={state.filters.dateTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_FILTER_DATE_TO", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||||
|
aria-label="Filter: to date"
|
||||||
|
/>
|
||||||
|
</FilterChip>
|
||||||
|
<FilterChip label="Headgate">
|
||||||
|
<select
|
||||||
|
value={state.filters.headgate}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_FILTER_HEADGATE", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||||
|
aria-label="Filter: headgate"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{state.headgates.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FilterChip>
|
||||||
|
<FilterChip label="User">
|
||||||
|
<select
|
||||||
|
value={state.filters.user}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_FILTER_USER", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||||
|
aria-label="Filter: user"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{state.users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FilterChip>
|
||||||
|
<FilterChip label="Via">
|
||||||
|
<select
|
||||||
|
value={state.filters.via}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_FILTER_VIA", value: e.target.value })
|
||||||
|
}
|
||||||
|
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||||||
|
aria-label="Filter: submission source"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="field">Field</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="app">App</option>
|
||||||
|
</select>
|
||||||
|
</FilterChip>
|
||||||
|
{(state.filters.dateFrom ||
|
||||||
|
state.filters.dateTo ||
|
||||||
|
state.filters.headgate ||
|
||||||
|
state.filters.user ||
|
||||||
|
state.filters.via) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: "CLEAR_FILTERS" })}
|
||||||
|
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
|
||||||
|
aria-label="Clear filters"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
|
||||||
|
{filteredEntries.length} of {state.entries.length} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<TableIcon />}
|
||||||
|
title="No entries match"
|
||||||
|
body={
|
||||||
|
state.entries.length === 0
|
||||||
|
? "No readings have been logged yet."
|
||||||
|
: "Try clearing the filters above."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
|
||||||
|
<th className="px-4 py-2.5">When</th>
|
||||||
|
<th className="px-4 py-2.5">User</th>
|
||||||
|
<th className="px-4 py-2.5">Headgate</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Measurement</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Gallons</th>
|
||||||
|
<th className="px-4 py-2.5">Method</th>
|
||||||
|
<th className="px-4 py-2.5">Via</th>
|
||||||
|
<th className="px-4 py-2.5" aria-label="Actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredEntries.map((e, i) => (
|
||||||
|
<tr
|
||||||
|
key={e.id}
|
||||||
|
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
|
||||||
|
i % 2 === 0 ? "" : "bg-[#fafaf7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
|
||||||
|
{formatDateTime(e.logged_at)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
|
||||||
|
{e.user_name}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
|
||||||
|
{e.headgate_name}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
|
||||||
|
{e.measurement}{" "}
|
||||||
|
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
|
||||||
|
{e.total_gallons != null
|
||||||
|
? e.total_gallons.toFixed(1)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
|
||||||
|
{e.method}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
|
||||||
|
e.submitted_via === "field"
|
||||||
|
? "bg-[#dcfce7] text-[#15803d]"
|
||||||
|
: "bg-[#e8ebe8] text-[#57694e]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.submitted_via}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditEntry(e)}
|
||||||
|
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
|
||||||
|
aria-label="Edit →"
|
||||||
|
>
|
||||||
|
Edit →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* UsersTab — CRUD surface for water users (irrigators + admins).
|
||||||
|
*
|
||||||
|
* Lists every user in a card grid, with add / reset-PIN / deactivate
|
||||||
|
* controls. PINs are shown one-at-a-time via the PinBanner primitive.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import AdminButton from "@/components/admin/design-system/AdminButton";
|
||||||
|
import {
|
||||||
|
createWaterUser,
|
||||||
|
deleteWaterUser,
|
||||||
|
resetWaterIrrigatorPin,
|
||||||
|
type AdminIrrigator,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
EmptyState,
|
||||||
|
Input,
|
||||||
|
PinBanner,
|
||||||
|
RoleLegend,
|
||||||
|
SectionHeader,
|
||||||
|
Select,
|
||||||
|
} from "../shared/Primitives";
|
||||||
|
import { UserIcon } from "../shared/icons";
|
||||||
|
import type { Action, NotifyFn, PinInfo, UserForm } from "../types";
|
||||||
|
|
||||||
|
export type UsersTabProps = {
|
||||||
|
brandId: string;
|
||||||
|
users: AdminIrrigator[];
|
||||||
|
canManage: boolean;
|
||||||
|
showAddUser: boolean;
|
||||||
|
newUser: UserForm;
|
||||||
|
savingUser: boolean;
|
||||||
|
newPin: PinInfo | null;
|
||||||
|
resetPin: PinInfo | null;
|
||||||
|
notify: NotifyFn;
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UsersTab({
|
||||||
|
brandId,
|
||||||
|
users,
|
||||||
|
canManage,
|
||||||
|
showAddUser,
|
||||||
|
newUser,
|
||||||
|
savingUser,
|
||||||
|
newPin,
|
||||||
|
resetPin,
|
||||||
|
notify,
|
||||||
|
dispatch,
|
||||||
|
}: UsersTabProps) {
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmedName = newUser.name.trim();
|
||||||
|
if (!trimmedName) return;
|
||||||
|
dispatch({ type: "SET_SAVING_USER", value: true });
|
||||||
|
const res = await createWaterUser(
|
||||||
|
brandId,
|
||||||
|
trimmedName,
|
||||||
|
newUser.role,
|
||||||
|
newUser.lang,
|
||||||
|
newUser.phone.trim() || null,
|
||||||
|
);
|
||||||
|
if (res.success && res.user && res.pin) {
|
||||||
|
dispatch({ type: "ADD_USER", user: res.user });
|
||||||
|
dispatch({
|
||||||
|
type: "SET_NEW_PIN",
|
||||||
|
pin: { name: res.user.name, pin: res.pin },
|
||||||
|
});
|
||||||
|
dispatch({ type: "RESET_NEW_USER" });
|
||||||
|
notify("User created");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
dispatch({ type: "SET_SAVING_USER", value: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPin(u: AdminIrrigator) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Reset PIN for "${u.name}"? Their current PIN will stop working.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const res = await resetWaterIrrigatorPin(u.id);
|
||||||
|
if (res.success && res.pin) {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_RESET_PIN",
|
||||||
|
pin: { name: u.name, pin: res.pin },
|
||||||
|
});
|
||||||
|
notify("PIN reset");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(u: AdminIrrigator) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Deactivate "${u.name}"? Their existing log entries will be preserved.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const res = await deleteWaterUser(u.id);
|
||||||
|
if (res.success) {
|
||||||
|
dispatch({ type: "DEACTIVATE_USER", id: u.id });
|
||||||
|
notify("User deactivated");
|
||||||
|
} else {
|
||||||
|
notify(res.error ?? "Failed", "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
index="§ 02"
|
||||||
|
title="Water Users"
|
||||||
|
subtitle="The people who log readings in the field. Each gets a 4-digit PIN. Admin role can manage headgates, users, and entries; Irrigators can only submit."
|
||||||
|
action={
|
||||||
|
canManage ? (
|
||||||
|
<AdminButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => dispatch({ type: "TOGGLE_ADD_USER" })}
|
||||||
|
>
|
||||||
|
{showAddUser ? "Cancel" : "+ Add User"}
|
||||||
|
</AdminButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{newPin && (
|
||||||
|
<PinBanner
|
||||||
|
title={`New PIN for ${newPin.name}`}
|
||||||
|
pin={newPin.pin}
|
||||||
|
onDismiss={() => dispatch({ type: "CLEAR_NEW_PIN" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{resetPin && (
|
||||||
|
<PinBanner
|
||||||
|
title={`Reset PIN for ${resetPin.name}`}
|
||||||
|
pin={resetPin.pin}
|
||||||
|
tone="warn"
|
||||||
|
onDismiss={() => dispatch({ type: "CLEAR_RESET_PIN" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddUser && canManage && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_140px_120px_1fr_auto]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={newUser.name}
|
||||||
|
onChange={(v) =>
|
||||||
|
dispatch({ type: "SET_NEW_USER_NAME", value: v })
|
||||||
|
}
|
||||||
|
placeholder="Full name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
value={newUser.role}
|
||||||
|
onChange={(v) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_NEW_USER_ROLE",
|
||||||
|
value: v as "irrigator" | "water_admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "irrigator", label: "Irrigator" },
|
||||||
|
{ value: "water_admin", label: "Admin" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Language"
|
||||||
|
value={newUser.lang}
|
||||||
|
onChange={(v) =>
|
||||||
|
dispatch({ type: "SET_NEW_USER_LANG", value: v })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "es", label: "Español" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
value={newUser.phone}
|
||||||
|
onChange={(v) =>
|
||||||
|
dispatch({ type: "SET_NEW_USER_PHONE", value: v })
|
||||||
|
}
|
||||||
|
placeholder="+1…"
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<AdminButton
|
||||||
|
type="submit"
|
||||||
|
disabled={savingUser}
|
||||||
|
isLoading={savingUser}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RoleLegend />
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UserIcon />}
|
||||||
|
title="No water users yet"
|
||||||
|
body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{users.map((u) => (
|
||||||
|
<li
|
||||||
|
key={u.id}
|
||||||
|
className={`flex items-center gap-3 rounded-xl border bg-white px-3 py-2 ${
|
||||||
|
u.active
|
||||||
|
? "border-[#d4d9d3]"
|
||||||
|
: "border-[#d4d9d3] bg-[#f5f3ef] opacity-70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar name={u.name} role={u.role} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-semibold text-[#1d1d1f]">
|
||||||
|
{u.name}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[11px] text-[#86868b]">
|
||||||
|
{u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} ·{" "}
|
||||||
|
{u.language_preference === "es" ? "ES" : "EN"}
|
||||||
|
{u.last_used_at
|
||||||
|
? ` · seen ${formatDate(u.last_used_at)}`
|
||||||
|
: " · never signed in"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleResetPin(u)}
|
||||||
|
className="text-[#57694e] hover:text-[#1a4d2e]"
|
||||||
|
aria-label="PIN"
|
||||||
|
>
|
||||||
|
PIN
|
||||||
|
</button>
|
||||||
|
<span className="text-[#d4d9d3]">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
className="text-[#b91c1c] hover:text-[#7f1d1d]"
|
||||||
|
aria-label="Off"
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the Water Log admin module.
|
||||||
|
*
|
||||||
|
* Split out of the original monolithic `WaterLogAdminPanel.tsx` so each
|
||||||
|
* tab / shared component can declare its own prop types without
|
||||||
|
* pulling in the full reducer surface.
|
||||||
|
*
|
||||||
|
* @see ./reducer.ts for State + Action.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
AdminEntry,
|
||||||
|
AdminHeadgate,
|
||||||
|
AdminIrrigator,
|
||||||
|
} from "@/actions/water-log/admin";
|
||||||
|
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────────────────
|
||||||
|
export type Toast = { msg: string; kind: "ok" | "err" };
|
||||||
|
|
||||||
|
// ── Forms ────────────────────────────────────────────────────────────────────
|
||||||
|
export type HgForm = { name: string; unit: string; notes: string };
|
||||||
|
export type UserForm = {
|
||||||
|
name: string;
|
||||||
|
role: "irrigator" | "water_admin";
|
||||||
|
lang: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── PIN disclosure ───────────────────────────────────────────────────────────
|
||||||
|
export type PinInfo = { name: string; pin: string };
|
||||||
|
|
||||||
|
// ── Report preview payload ───────────────────────────────────────────────────
|
||||||
|
export type ReportPreviewData = {
|
||||||
|
rows: import("@/lib/water-log-reporting").WaterLogReportRow[];
|
||||||
|
total: number;
|
||||||
|
unit: string;
|
||||||
|
dateLabel: string;
|
||||||
|
dateIso: string;
|
||||||
|
recipientName?: string;
|
||||||
|
inSeason: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Reducer surface (mirrored here so callers don't import reducer internals)
|
||||||
|
export type State = {
|
||||||
|
headgates: AdminHeadgate[];
|
||||||
|
users: AdminIrrigator[];
|
||||||
|
entries: AdminEntry[];
|
||||||
|
toast: Toast | null;
|
||||||
|
forms: {
|
||||||
|
showAddHg: boolean;
|
||||||
|
newHg: HgForm;
|
||||||
|
savingHg: boolean;
|
||||||
|
showAddUser: boolean;
|
||||||
|
newUser: UserForm;
|
||||||
|
savingUser: boolean;
|
||||||
|
newPin: PinInfo | null;
|
||||||
|
resetPin: PinInfo | null;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
headgate: string;
|
||||||
|
user: string;
|
||||||
|
via: string;
|
||||||
|
};
|
||||||
|
csvLoading: boolean;
|
||||||
|
today: string;
|
||||||
|
todayLabel: string;
|
||||||
|
showReportSettings: boolean;
|
||||||
|
seasonStart: IrrigationSeasonSettings;
|
||||||
|
recipientName: string;
|
||||||
|
reportPreview: ReportPreviewData | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
// Headgates
|
||||||
|
| { type: "ADD_HEADGATE"; headgate: AdminHeadgate }
|
||||||
|
| { type: "REMOVE_HEADGATE"; id: string }
|
||||||
|
| { type: "UPDATE_HEADGATE_TOKEN"; id: string; token: string }
|
||||||
|
// Users
|
||||||
|
| { type: "ADD_USER"; user: AdminIrrigator }
|
||||||
|
| { type: "DEACTIVATE_USER"; id: string }
|
||||||
|
// Toast
|
||||||
|
| { type: "SET_TOAST"; toast: Toast }
|
||||||
|
| { type: "CLEAR_TOAST" }
|
||||||
|
// Forms - Headgate
|
||||||
|
| { type: "TOGGLE_ADD_HG" }
|
||||||
|
| { type: "SET_NEW_HG_NAME"; value: string }
|
||||||
|
| { type: "SET_NEW_HG_UNIT"; value: string }
|
||||||
|
| { type: "SET_NEW_HG_NOTES"; value: string }
|
||||||
|
| { type: "RESET_NEW_HG" }
|
||||||
|
| { type: "SET_SAVING_HG"; value: boolean }
|
||||||
|
// Forms - User
|
||||||
|
| { type: "TOGGLE_ADD_USER" }
|
||||||
|
| { type: "SET_NEW_USER_NAME"; value: string }
|
||||||
|
| { type: "SET_NEW_USER_ROLE"; value: "irrigator" | "water_admin" }
|
||||||
|
| { type: "SET_NEW_USER_LANG"; value: string }
|
||||||
|
| { type: "SET_NEW_USER_PHONE"; value: string }
|
||||||
|
| { type: "RESET_NEW_USER" }
|
||||||
|
| { type: "SET_SAVING_USER"; value: boolean }
|
||||||
|
| { type: "SET_NEW_PIN"; pin: PinInfo }
|
||||||
|
| { type: "CLEAR_NEW_PIN" }
|
||||||
|
| { type: "SET_RESET_PIN"; pin: PinInfo }
|
||||||
|
| { type: "CLEAR_RESET_PIN" }
|
||||||
|
// Filters
|
||||||
|
| { type: "SET_FILTER_DATE_FROM"; value: string }
|
||||||
|
| { type: "SET_FILTER_DATE_TO"; value: string }
|
||||||
|
| { type: "SET_FILTER_HEADGATE"; value: string }
|
||||||
|
| { type: "SET_FILTER_USER"; value: string }
|
||||||
|
| { type: "SET_FILTER_VIA"; value: string }
|
||||||
|
| { type: "CLEAR_FILTERS" }
|
||||||
|
// Misc UI
|
||||||
|
| { type: "SET_CSV_LOADING"; value: boolean }
|
||||||
|
| { type: "SET_TODAY"; today: string; label: string }
|
||||||
|
| { type: "TOGGLE_REPORT_SETTINGS" }
|
||||||
|
| { type: "SET_SEASON_START"; value: IrrigationSeasonSettings }
|
||||||
|
| { type: "SET_RECIPIENT_NAME"; value: string }
|
||||||
|
| { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData }
|
||||||
|
| { type: "CLOSE_REPORT_PREVIEW" };
|
||||||
|
|
||||||
|
// ── Convenience ──────────────────────────────────────────────────────────────
|
||||||
|
export type NotifyFn = (msg: string, kind?: "ok" | "err") => void;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* FieldInstallPrompt — install prompt for the worker sub-PWA.
|
||||||
|
*
|
||||||
|
* Cycle 6 sibling of `InstallPrompt` (mounted from the root layout).
|
||||||
|
* This one fires only on /water routes and uses the field-PWA
|
||||||
|
* branding (forest green icon, "Water Log — Tuxedo") so the prompt
|
||||||
|
* that surfaces matches the manifest that will be installed.
|
||||||
|
*
|
||||||
|
* Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt;
|
||||||
|
* split into a separate component file because the worker surface
|
||||||
|
* has its own theming + copy.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptState = {
|
||||||
|
deferred: BeforeInstallPromptEvent | null;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromptAction =
|
||||||
|
| { type: "captured"; event: BeforeInstallPromptEvent }
|
||||||
|
| { type: "show" }
|
||||||
|
| { type: "hide" }
|
||||||
|
| { type: "dismissed" }
|
||||||
|
| { type: "reset" };
|
||||||
|
|
||||||
|
function promptReducer(state: PromptState, action: PromptAction): PromptState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "captured":
|
||||||
|
return { deferred: action.event, visible: false };
|
||||||
|
case "show":
|
||||||
|
return state.deferred ? { ...state, visible: true } : state;
|
||||||
|
case "hide":
|
||||||
|
return { deferred: null, visible: false };
|
||||||
|
case "dismissed":
|
||||||
|
return { ...state, visible: false };
|
||||||
|
case "reset":
|
||||||
|
return { deferred: null, visible: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false };
|
||||||
|
|
||||||
|
export function FieldInstallPrompt() {
|
||||||
|
const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE);
|
||||||
|
const isInstalled = useSyncExternalStore(
|
||||||
|
() => () => {},
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
// iOS Safari uses navigator.standalone for the home-screen PWA
|
||||||
|
// launch. Cover both.
|
||||||
|
(typeof (navigator as Navigator & { standalone?: boolean }).standalone ===
|
||||||
|
"boolean" &&
|
||||||
|
(navigator as Navigator & { standalone?: boolean }).standalone === true)),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const mounted = useSyncExternalStore(
|
||||||
|
() => () => {},
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInstalled) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const handleBeforeInstall = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch({ type: "captured", event: e as BeforeInstallPromptEvent });
|
||||||
|
// Show the field prompt sooner than the main one — workers
|
||||||
|
// know they want this installed on day one.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
dispatch({ type: "show" });
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
dispatch({ type: "reset" });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
|
||||||
|
window.addEventListener("appinstalled", handleAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
|
||||||
|
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||||
|
};
|
||||||
|
}, [isInstalled]);
|
||||||
|
|
||||||
|
const handleInstall = useCallback(async () => {
|
||||||
|
const deferred = prompt.deferred;
|
||||||
|
if (!deferred) return;
|
||||||
|
|
||||||
|
await deferred.prompt();
|
||||||
|
const { outcome } = await deferred.userChoice;
|
||||||
|
void outcome;
|
||||||
|
dispatch({ type: "reset" });
|
||||||
|
}, [prompt.deferred]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
dispatch({ type: "dismissed" });
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("field-pwa-install-dismissed", "true");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-[#1a4d2e]/30 p-4 z-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 bg-[#1a4d2e] rounded-xl flex items-center justify-center shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-[#1a4d2e]">
|
||||||
|
Install Water Log
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[rgba(60,60,67,0.65)] mt-1">
|
||||||
|
One tap to a full-screen PIN entry from your home screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-1 px-4 py-2 text-sm font-medium text-[rgba(60,60,67,0.55)] hover:text-[rgba(60,60,67,0.85)] transition-colors"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 px-4 bg-[#1a4d2e] text-white rounded-lg text-sm font-medium hover:bg-[#14432a] py-2 transition-colors"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FieldInstallPrompt;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* FieldSWRegistration — registers /sw-field.js with scope=/water.
|
||||||
|
*
|
||||||
|
* Cycle 6 component used by the worker sub-PWA. The component is
|
||||||
|
* mounted from src/app/water/layout.tsx (a thin client boundary
|
||||||
|
* inside the otherwise-server layout) so the registration only
|
||||||
|
* happens on /water routes — never on /admin, /tuxedo, etc.
|
||||||
|
*
|
||||||
|
* No-ops on:
|
||||||
|
* - Server (guards with typeof navigator)
|
||||||
|
* - repeat mounts (uses a module-scoped flag so React strict-mode
|
||||||
|
* double-mount doesn't double-register)
|
||||||
|
*
|
||||||
|
* Same registration pattern as next-pwa — we use the raw
|
||||||
|
* ServiceWorkerContainer API to stay framework-free.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
export function FieldSWRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof navigator === "undefined") return;
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
void navigator.serviceWorker
|
||||||
|
.register("/sw-field.js", { scope: "/water" })
|
||||||
|
.catch((err) => {
|
||||||
|
// Surface to console for ops; never block the UI. The
|
||||||
|
// app works without the SW — it just loses offline shell
|
||||||
|
// caching for /water.
|
||||||
|
console.error("[field-sw] registration failed", err);
|
||||||
|
// Allow a retry on a later mount.
|
||||||
|
registered = false;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FieldSWRegistration;
|
||||||
@@ -5,13 +5,14 @@
|
|||||||
* Tuxedo Water Log experience.
|
* Tuxedo Water Log experience.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
|
* pin ─verify─▶ tabbed shell
|
||||||
* ▲ │ │
|
* │
|
||||||
* │ └───────────────(logout)─────────────────┤
|
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
|
||||||
* └─(logout)─────(anywhere)──────────────────────────────┘
|
* │ ▲ │
|
||||||
* │
|
* │ │ │
|
||||||
* success ── Log Another ─▶ log (cleared) ─┘
|
* │ └───────── (logout) ────────────┤
|
||||||
* success ── Back to Headgates ─▶ headgates
|
* │
|
||||||
|
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
|
||||||
*
|
*
|
||||||
* Hydration:
|
* Hydration:
|
||||||
* - On mount we peek at `document.cookie` for the `wl_session`
|
* - On mount we peek at `document.cookie` for the `wl_session`
|
||||||
@@ -21,11 +22,19 @@
|
|||||||
* who already signed in once in their shift shouldn't have to
|
* who already signed in once in their shift shouldn't have to
|
||||||
* re-enter their PIN every time they reopen the page.
|
* re-enter their PIN every time they reopen the page.
|
||||||
*
|
*
|
||||||
|
* Cycle 4 — the existing PIN is unified with the time-tracking PIN:
|
||||||
|
* after `verifyWaterPin` succeeds we also best-effort call
|
||||||
|
* `verifyTimeTrackingPin` with the same digits. If the worker exists
|
||||||
|
* in `time_tracking_workers`, the time-tracking session cookie is set
|
||||||
|
* too, so they never re-enter their PIN on the Time tab.
|
||||||
|
*
|
||||||
* Server actions used:
|
* Server actions used:
|
||||||
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
||||||
* - `getWaterHeadgates` → loads the list of active gates
|
* - `getWaterHeadgates` → loads the list of active gates
|
||||||
* - `submitWaterEntry` → posts a new reading
|
* - `submitWaterEntry` → posts a new reading
|
||||||
* - `logoutWater` → clears the cookie + session row
|
* - `logoutWater` → clears the cookie + session row
|
||||||
|
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
|
||||||
|
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -34,13 +43,28 @@ import {
|
|||||||
getWaterHeadgates,
|
getWaterHeadgates,
|
||||||
logoutWater,
|
logoutWater,
|
||||||
} from "@/actions/water-log/field";
|
} from "@/actions/water-log/field";
|
||||||
import type { FieldHeadgate, Screen, SubmittedLog } from "./types";
|
import {
|
||||||
|
verifyTimeTrackingPin,
|
||||||
|
logoutTimeTracking,
|
||||||
|
} from "@/actions/time-tracking/field";
|
||||||
|
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
|
||||||
|
import type {
|
||||||
|
FieldHeadgate,
|
||||||
|
Screen,
|
||||||
|
SubmittedLog,
|
||||||
|
ActiveTab,
|
||||||
|
} from "./types";
|
||||||
import { MobileWaterPinScreen } from "./PinScreen";
|
import { MobileWaterPinScreen } from "./PinScreen";
|
||||||
import { MobileWaterHeadgateList } from "./HeadgateList";
|
import { MobileWaterHeadgateList } from "./HeadgateList";
|
||||||
import { MobileWaterLogForm } from "./LogForm";
|
import { MobileWaterLogForm } from "./LogForm";
|
||||||
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
||||||
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
|
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
|
||||||
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
|
import {
|
||||||
|
TUXEDO_BRAND_ID,
|
||||||
|
TUXEDO_BRAND_NAME,
|
||||||
|
TUXEDO_BRAND_ACCENT,
|
||||||
|
TUXEDO_BRAND_LOGO_URL,
|
||||||
|
} from "@/lib/water-log/brand";
|
||||||
import { useLang } from "@/lib/water-log/lang-context";
|
import { useLang } from "@/lib/water-log/lang-context";
|
||||||
|
|
||||||
// Server-side auth errors that should bounce the user to the PIN
|
// Server-side auth errors that should bounce the user to the PIN
|
||||||
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
|
|||||||
|
|
||||||
export function MobileWaterApp() {
|
export function MobileWaterApp() {
|
||||||
const [screen, setScreen] = useState<Screen>("pin");
|
const [screen, setScreen] = useState<Screen>("pin");
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
|
||||||
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
||||||
// so it's always up-to-date when the user toggles via the toggle
|
// so it's always up-to-date when the user toggles via the toggle
|
||||||
// in the headgate list header.
|
// in the headgate list header.
|
||||||
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
|
|||||||
setIrrigatorName(result.name ?? null);
|
setIrrigatorName(result.name ?? null);
|
||||||
setScreen("headgates");
|
setScreen("headgates");
|
||||||
void loadHeadgates();
|
void loadHeadgates();
|
||||||
|
// Cycle 4 — unify the PIN with Time Tracking. Best-effort: a
|
||||||
|
// worker who isn't also set up as a time-tracking worker just
|
||||||
|
// sees the regular PIN screen on the Time tab. Never blocks
|
||||||
|
// the water flow, never surfaces a 500 to the user.
|
||||||
|
void verifyTimeTrackingPin(TUXEDO_BRAND_ID, pin).catch(() => {});
|
||||||
},
|
},
|
||||||
[loadHeadgates],
|
[loadHeadgates],
|
||||||
);
|
);
|
||||||
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
|
|||||||
|
|
||||||
// ── Logout (from anywhere) ────────────────────────────────────
|
// ── Logout (from anywhere) ────────────────────────────────────
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
|
// Cycle 4 — clear BOTH cookies. The water flow only knew about
|
||||||
|
// `wl_session`; the Time tab introduced `time_tracking_session`.
|
||||||
try {
|
try {
|
||||||
await logoutWater();
|
await logoutWater();
|
||||||
} catch {
|
} catch {
|
||||||
// Even if the server logout fails, clear local state so the
|
// Even if the server logout fails, clear local state so the
|
||||||
// user isn't stuck.
|
// user isn't stuck.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await logoutTimeTracking();
|
||||||
|
} catch {
|
||||||
|
// Same — defensive cleanup.
|
||||||
|
}
|
||||||
setIrrigatorName(null);
|
setIrrigatorName(null);
|
||||||
setHeadgates([]);
|
setHeadgates([]);
|
||||||
setSelectedHeadgate(null);
|
setSelectedHeadgate(null);
|
||||||
setSubmittedLog(null);
|
setSubmittedLog(null);
|
||||||
|
setActiveTab("headgates");
|
||||||
setScreen("pin");
|
setScreen("pin");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Tab change (Cycle 4) ──────────────────────────────────────
|
||||||
|
const handleTabChange = useCallback((next: ActiveTab) => {
|
||||||
|
setActiveTab(next);
|
||||||
|
if (next === "time") {
|
||||||
|
// Switching to Time drops the in-flight water flow (selected
|
||||||
|
// headgate + submitted log) so it doesn't linger when the
|
||||||
|
// worker comes back to Headgates. The headgate list itself
|
||||||
|
// is cached and will reload on demand.
|
||||||
|
setSubmittedLog(null);
|
||||||
|
setSelectedHeadgate(null);
|
||||||
|
setScreen("time");
|
||||||
|
} else {
|
||||||
|
// Switching back to Headgates — restore the flow at the
|
||||||
|
// headgate list (we always have that state cached; the
|
||||||
|
// loadHeadgates effect picks up fresh data on mount).
|
||||||
|
if (headgates.length === 0) void loadHeadgates();
|
||||||
|
setScreen("headgates");
|
||||||
|
}
|
||||||
|
}, [headgates.length, loadHeadgates]);
|
||||||
|
|
||||||
// ── Switch on the current screen ──────────────────────────────
|
// ── Switch on the current screen ──────────────────────────────
|
||||||
// Compute the screen element first, then wrap it once in
|
// Compute the screen element first, then wrap it once in
|
||||||
// <LangProvider>. Wrapping per-case would remount the provider on
|
// <LangProvider>. Wrapping per-case would remount the provider on
|
||||||
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "time":
|
||||||
|
// Cycle 4 — Tuxedo-only Time tab. Same brand id / name /
|
||||||
|
// accent as the standalone /tuxedo/time-clock route so the
|
||||||
|
// embedded surface matches the standalone one pixel-for-pixel.
|
||||||
|
screenElement = (
|
||||||
|
<TimeTrackingFieldClient
|
||||||
|
brandId={TUXEDO_BRAND_ID}
|
||||||
|
brandName={TUXEDO_BRAND_NAME}
|
||||||
|
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||||
|
logoUrl={TUXEDO_BRAND_LOGO_URL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `initialLang` is computed by the parent <LangProvider> in
|
// ── Post-PIN shell ────────────────────────────────────────────
|
||||||
// WaterFieldClient — we just return the screen element here.
|
// The pre-PIN screen is bare (no nav). After PIN we wrap the
|
||||||
return screenElement;
|
// screen element in a sticky tab bar so the worker can flip
|
||||||
|
// between Headgates and Time without re-entering credentials.
|
||||||
|
const isPostPin = screen !== "pin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{isPostPin && (
|
||||||
|
<TabBar
|
||||||
|
activeTab={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
irrigatorName={irrigatorName ?? undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1">{screenElement}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TabBar (Cycle 4) ───────────────────────────────────────────
|
||||||
|
// Sticky two-tab top nav for the Tuxedo worker experience.
|
||||||
|
// Stays visible across Headgates → Log → Success AND on the Time
|
||||||
|
// tab. Extracted as a presentational component (no server actions,
|
||||||
|
// no effects) so it's pure and easy to iterate on visually.
|
||||||
|
function TabBar({
|
||||||
|
activeTab,
|
||||||
|
onChange,
|
||||||
|
onLogout,
|
||||||
|
irrigatorName,
|
||||||
|
}: {
|
||||||
|
activeTab: ActiveTab;
|
||||||
|
onChange: (next: ActiveTab) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
irrigatorName?: string;
|
||||||
|
}) {
|
||||||
|
const t = useLang().t;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Worker navigation"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "headgates"}
|
||||||
|
onClick={() => onChange("headgates")}
|
||||||
|
className={
|
||||||
|
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||||
|
(activeTab === "headgates"
|
||||||
|
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||||
|
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.tab.headgates}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "time"}
|
||||||
|
onClick={() => onChange("time")}
|
||||||
|
className={
|
||||||
|
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||||
|
(activeTab === "time"
|
||||||
|
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||||
|
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.tab.time}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{irrigatorName && (
|
||||||
|
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
|
||||||
|
{irrigatorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
|
||||||
|
aria-label="Log out"
|
||||||
|
>
|
||||||
|
{t.tab.logout}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MobileWaterApp;
|
export default MobileWaterApp;
|
||||||
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
|
|||||||
return unit === "holes";
|
return unit === "holes";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level screen identifiers — drives the state machine. */
|
/** Top-level screen identifiers — drives the state machine.
|
||||||
export type Screen = "pin" | "headgates" | "log" | "success";
|
*
|
||||||
|
* Cycle 4 adds "time" for the Tuxedo-only Time tab, which renders
|
||||||
|
* `TimeTrackingFieldClient` instead of the existing water-entry
|
||||||
|
* screens. The "pin" → "headgates"/"log"/"success" flow is unchanged.
|
||||||
|
*/
|
||||||
|
export type Screen = "pin" | "headgates" | "log" | "success" | "time";
|
||||||
|
|
||||||
|
/** Tuxedo-only Time-tab branding (Cycle 4). */
|
||||||
|
export type ActiveTab = "headgates" | "time";
|
||||||
|
|
||||||
/** A submitted log entry (only kept locally for the success screen). */
|
/** A submitted log entry (only kept locally for the success screen). */
|
||||||
export type SubmittedLog = {
|
export type SubmittedLog = {
|
||||||
|
|||||||
@@ -12,3 +12,12 @@
|
|||||||
* which is the standard pattern used throughout the rest of the app.
|
* which is the standard pattern used throughout the rest of the app.
|
||||||
*/
|
*/
|
||||||
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
|
/** Display surfaces (Cycle 4) — single source of truth for the
|
||||||
|
* Tuxedo worker experience so the standalone `/tuxedo/time-clock`
|
||||||
|
* page and the embedded Time tab inside `/water` stay in sync.
|
||||||
|
* Bump these together when the brand refreshes its accent/logo. */
|
||||||
|
export const TUXEDO_BRAND_NAME = "Tuxedo Corn";
|
||||||
|
export const TUXEDO_BRAND_ACCENT = "green";
|
||||||
|
/** `null` until the Tuxedo brand gets a logo asset uploaded. */
|
||||||
|
export const TUXEDO_BRAND_LOGO_URL: string | null = null;
|
||||||
@@ -138,6 +138,13 @@ export type Labels = {
|
|||||||
minutesAgo: (n: number) => string;
|
minutesAgo: (n: number) => string;
|
||||||
hoursAgo: (n: number) => string;
|
hoursAgo: (n: number) => string;
|
||||||
};
|
};
|
||||||
|
/** Tuxedo worker post-PIN shell (Cycle 4) — names for the two
|
||||||
|
* top tabs and the inline logout affordance. */
|
||||||
|
tab: {
|
||||||
|
headgates: string;
|
||||||
|
time: string;
|
||||||
|
logout: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LABELS: Record<Lang, Labels> = {
|
export const LABELS: Record<Lang, Labels> = {
|
||||||
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
|
|||||||
minutesAgo: (n) => `${n}m ago`,
|
minutesAgo: (n) => `${n}m ago`,
|
||||||
hoursAgo: (n) => `${n}h ago`,
|
hoursAgo: (n) => `${n}h ago`,
|
||||||
},
|
},
|
||||||
|
tab: {
|
||||||
|
headgates: "Headgates",
|
||||||
|
time: "Time",
|
||||||
|
logout: "Log out",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
common: {
|
common: {
|
||||||
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
|
|||||||
minutesAgo: (n) => `hace ${n}m`,
|
minutesAgo: (n) => `hace ${n}m`,
|
||||||
hoursAgo: (n) => `hace ${n}h`,
|
hoursAgo: (n) => `hace ${n}h`,
|
||||||
},
|
},
|
||||||
|
tab: {
|
||||||
|
headgates: "Compuertas",
|
||||||
|
time: "Tiempo",
|
||||||
|
logout: "Salir",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
waterSmartsheetSyncLog,
|
waterSmartsheetSyncLog,
|
||||||
type SmartsheetColumnMapping,
|
type SmartsheetColumnMapping,
|
||||||
} from "@/db/schema/water-log";
|
} from "@/db/schema/water-log";
|
||||||
import { decryptToken } from "@/lib/crypto";
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||||
import {
|
import {
|
||||||
addRow,
|
addRow,
|
||||||
findRowByColumn,
|
findRowByColumn,
|
||||||
@@ -159,24 +159,38 @@ export async function syncEntryToSmartsheet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Decrypt token + build cells ──
|
// ── 3. Resolve token from the workspace (Cycle 7) ──
|
||||||
let token: string;
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
try {
|
if (!ws.ok) {
|
||||||
token = decryptToken({
|
// "no_workspace" + "decryption_failed" are real failures — log
|
||||||
cipher: config.encryptedApiToken,
|
// them, bump attempts, and back off. "connection_disabled" is
|
||||||
iv: config.tokenIv,
|
// an intentional admin pause — skip cleanly, don't retry, don't
|
||||||
authTag: config.tokenAuthTag,
|
// pollute the activity log with "disabled" failures.
|
||||||
});
|
if (ws.reason === "connection_disabled") {
|
||||||
} catch (err) {
|
return finalizeSkip(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue?.id ?? null,
|
||||||
|
"workspace paused",
|
||||||
|
queue?.smartsheetRowId ?? null,
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reason =
|
||||||
|
ws.reason === "no_workspace"
|
||||||
|
? "Smartsheet workbook not connected"
|
||||||
|
: `workspace token unreadable: ${ws.error}`;
|
||||||
return recordFailure(
|
return recordFailure(
|
||||||
db,
|
db,
|
||||||
brandId,
|
brandId,
|
||||||
entryId,
|
entryId,
|
||||||
queue?.attempts ?? 0,
|
queue?.attempts ?? 0,
|
||||||
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`,
|
reason,
|
||||||
start,
|
start,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const token = ws.token;
|
||||||
|
|
||||||
const cells = buildCells(
|
const cells = buildCells(
|
||||||
config.columnMapping,
|
config.columnMapping,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Per-brand drain summary for the cron route.
|
||||||
|
*
|
||||||
|
* Cycle 9 — extracted from `/api/time-tracking/smartsheet-sync/route.ts`
|
||||||
|
* so the discriminated-union narrowing (`result.skipped === true`)
|
||||||
|
* lives in a pure, unit-testable module instead of inside an HTTP
|
||||||
|
* handler. The route is now a thin shell that authenticates, parses
|
||||||
|
* the body, fans out to this helper, and aggregates the results.
|
||||||
|
*
|
||||||
|
* The TT sync service (`runScheduledTTSync`) returns either:
|
||||||
|
* - a regular `TTDrainResult` (counters)
|
||||||
|
* - `{ skipped: true, reason }` when the brand isn't due yet
|
||||||
|
*
|
||||||
|
* Both branches carry a `skipped` property, so we narrow on the
|
||||||
|
* literal `=== true` rather than the `in` operator — TS doesn't
|
||||||
|
* otherwise distinguish the two cases.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
export type DrainSummary = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
/** Set when the brand isn't due yet (frequency gate). */
|
||||||
|
skippedReason?: string;
|
||||||
|
/** Set when `run*` itself threw. */
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTDrainCounters = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTDrainSkipped = {
|
||||||
|
skipped: true;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the return shape of `runScheduledTTSync` (see
|
||||||
|
* `src/services/time-tracking-smartsheet-sync.ts`). The two branches
|
||||||
|
* share a `skipped` property by name but differ in type — literal
|
||||||
|
* `true` vs `number` — so callers narrow on `result.skipped === true`.
|
||||||
|
*/
|
||||||
|
export type TTDrainResult = TTDrainCounters | TTDrainSkipped;
|
||||||
|
|
||||||
|
export type DrainRunner = (brandId: string) => Promise<TTDrainResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the per-brand drain and translate the union into a flat
|
||||||
|
* summary that the cron route can aggregate.
|
||||||
|
*
|
||||||
|
* Errors are caught and surfaced in `error`, so one failing brand
|
||||||
|
* never aborts the batch.
|
||||||
|
*/
|
||||||
|
export async function drainOneBrand(
|
||||||
|
brandId: string,
|
||||||
|
run: DrainRunner,
|
||||||
|
): Promise<DrainSummary> {
|
||||||
|
try {
|
||||||
|
const result = await run(brandId);
|
||||||
|
if (result.skipped === true) {
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
skippedReason: result.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: result.considered,
|
||||||
|
synced: result.synced,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum a list of per-brand summaries into route-level totals. */
|
||||||
|
export function summarizeDrains(results: DrainSummary[]) {
|
||||||
|
return results.reduce(
|
||||||
|
(acc, r) => ({
|
||||||
|
considered: acc.considered + r.considered,
|
||||||
|
synced: acc.synced + r.synced,
|
||||||
|
skipped: acc.skipped + r.skipped,
|
||||||
|
failed: acc.failed + r.failed,
|
||||||
|
}),
|
||||||
|
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
/**
|
||||||
|
* Time Tracking → Smartsheet sync engine.
|
||||||
|
*
|
||||||
|
* Cycle 5 — mirrors the water-log smartsheet-sync service so a brand
|
||||||
|
* can push every closed clock-out to a configured Smartsheet sheet.
|
||||||
|
*
|
||||||
|
* Callers:
|
||||||
|
* - `enqueueClockOutForSync()` from `clockOutWorker` (realtime)
|
||||||
|
* - `/api/time-tracking/smartsheet-sync` cron (every 15 min / hourly)
|
||||||
|
* - Manual retry via the admin UI's "Retry now" button (future)
|
||||||
|
*
|
||||||
|
* Public surface:
|
||||||
|
* - `syncLogToSmartsheet(brandId, logId)` — push one clock-out
|
||||||
|
* - `drainTTSyncQueue(brandId, opts)` — process pending / due retries
|
||||||
|
* - `runScheduledTTSync(brandId)` — entry point for the cron route;
|
||||||
|
* loads the config + applies per-frequency filtering
|
||||||
|
*
|
||||||
|
* Concurrency model:
|
||||||
|
* - One sync runs at a time per brand. The SELECT … FOR UPDATE SKIP
|
||||||
|
* LOCKED in `drainTTSyncQueue` lets multiple cron runs co-exist
|
||||||
|
* safely across brands without double-processing.
|
||||||
|
*
|
||||||
|
* Retry strategy:
|
||||||
|
* - On failure, `attempts++` and `next_attempt_at = now() + 2^attempts min`,
|
||||||
|
* capped at 60 minutes. After `maxAttempts` (default 5), the row
|
||||||
|
* stays `failed` and the admin sees it in Recent Activity.
|
||||||
|
*
|
||||||
|
* Error sanitization:
|
||||||
|
* - Smartsheet errors may include token echoes in odd cases. The
|
||||||
|
* service strips anything that looks like our token before
|
||||||
|
* persisting `last_error`.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { and, eq, inArray, lte } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import {
|
||||||
|
timeTrackingLogs,
|
||||||
|
timeTrackingWorkers,
|
||||||
|
timeTrackingTasks,
|
||||||
|
timeTrackingSmartsheetConfig,
|
||||||
|
timeTrackingSmartsheetSyncQueue,
|
||||||
|
timeTrackingSmartsheetSyncLog,
|
||||||
|
type TTSmartsheetColumnMapping,
|
||||||
|
} from "@/db/schema/time-tracking";
|
||||||
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||||
|
import {
|
||||||
|
addRow,
|
||||||
|
findRowByColumn,
|
||||||
|
SmartsheetApiError,
|
||||||
|
type SmartsheetCell,
|
||||||
|
} from "@/lib/smartsheet";
|
||||||
|
|
||||||
|
const DEFAULT_BATCH_SIZE = 50;
|
||||||
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
||||||
|
const MAX_BACKOFF_MINUTES = 60;
|
||||||
|
|
||||||
|
export type TTSyncResult =
|
||||||
|
| {
|
||||||
|
status: "synced";
|
||||||
|
smartsheetRowId: string;
|
||||||
|
attempts: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "skipped";
|
||||||
|
reason: string;
|
||||||
|
smartsheetRowId: string | null;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "failed";
|
||||||
|
error: string;
|
||||||
|
attempts: number;
|
||||||
|
durationMs: number;
|
||||||
|
retryAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTDrainResult = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Strip anything that looks like our token from an error string.
|
||||||
|
* Smartsheet 4xx echoes the bearer token in some `WWW-Authenticate`
|
||||||
|
* headers; we never want a token footgun in the UI. */
|
||||||
|
function sanitizeError(raw: string, plaintextToken: string): string {
|
||||||
|
if (!raw) return raw;
|
||||||
|
if (!plaintextToken) return raw;
|
||||||
|
return raw.split(plaintextToken).join("[redacted-token]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a `log_id` cell fingerprint — the row ID we look up on the
|
||||||
|
* sheet for dedup. Stable and unique per row. */
|
||||||
|
function logIdFingerprint(logId: string): string {
|
||||||
|
return logId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a clock-in/out pair to a decimal-hours string ("7.42"). */
|
||||||
|
function hoursBetween(clockIn: Date, clockOut: Date, lunchMinutes: number) {
|
||||||
|
const ms = clockOut.getTime() - clockIn.getTime() - lunchMinutes * 60_000;
|
||||||
|
return Math.max(0, ms / 3_600_000).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the cells array from a closed log row using the brand's
|
||||||
|
* column mapping. Returns null if the mapping is missing required
|
||||||
|
* fields (log_id / clock_in). */
|
||||||
|
function buildCells(
|
||||||
|
mapping: TTSmartsheetColumnMapping,
|
||||||
|
values: {
|
||||||
|
logId: string;
|
||||||
|
clockInIso: string;
|
||||||
|
clockOutIso: string;
|
||||||
|
workerName: string;
|
||||||
|
taskName: string;
|
||||||
|
hours: string;
|
||||||
|
lunchMinutes: number;
|
||||||
|
notes: string | null;
|
||||||
|
},
|
||||||
|
): SmartsheetCell[] | null {
|
||||||
|
if (!mapping.log_id || !mapping.clock_in) return null;
|
||||||
|
const cells: SmartsheetCell[] = [
|
||||||
|
{ columnId: mapping.log_id, value: logIdFingerprint(values.logId) },
|
||||||
|
{ columnId: mapping.clock_in, value: values.clockInIso },
|
||||||
|
];
|
||||||
|
if (mapping.clock_out && values.clockOutIso) {
|
||||||
|
cells.push({ columnId: mapping.clock_out, value: values.clockOutIso });
|
||||||
|
}
|
||||||
|
if (mapping.worker && values.workerName) {
|
||||||
|
cells.push({ columnId: mapping.worker, value: values.workerName });
|
||||||
|
}
|
||||||
|
if (mapping.task && values.taskName) {
|
||||||
|
cells.push({ columnId: mapping.task, value: values.taskName });
|
||||||
|
}
|
||||||
|
if (mapping.hours && values.hours) {
|
||||||
|
cells.push({ columnId: mapping.hours, value: values.hours });
|
||||||
|
}
|
||||||
|
if (mapping.lunch_minutes) {
|
||||||
|
cells.push({
|
||||||
|
columnId: mapping.lunch_minutes,
|
||||||
|
value: String(values.lunchMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mapping.notes) {
|
||||||
|
cells.push({
|
||||||
|
columnId: mapping.notes,
|
||||||
|
value: values.notes ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core: sync one closed log row ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function syncLogToSmartsheet(
|
||||||
|
brandId: string,
|
||||||
|
logId: string,
|
||||||
|
): Promise<TTSyncResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// Load config + queue row + log + worker in parallel.
|
||||||
|
const [configRows, queueRows, logRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingSmartsheetSyncQueue.brandId, brandId),
|
||||||
|
eq(timeTrackingSmartsheetSyncQueue.logId, logId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
log: timeTrackingLogs,
|
||||||
|
workerName: timeTrackingWorkers.name,
|
||||||
|
})
|
||||||
|
.from(timeTrackingLogs)
|
||||||
|
.leftJoin(
|
||||||
|
timeTrackingWorkers,
|
||||||
|
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
|
||||||
|
)
|
||||||
|
.where(eq(timeTrackingLogs.id, logId))
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = configRows[0];
|
||||||
|
const queueRow = queueRows[0];
|
||||||
|
const logRow = logRows[0];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: "Smartsheet config not set up for this brand",
|
||||||
|
attempts: 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: new Date(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!config.syncEnabled) {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "skipped",
|
||||||
|
reason: "Sync disabled in config",
|
||||||
|
smartsheetRowId: queueRow?.smartsheetRowId ?? null,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!logRow || !logRow.log.clockOut) {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: "Clock-out row not found or still open",
|
||||||
|
attempts: 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: new Date(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!queueRow) {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: "No sync queue row — was the clock-out queued?",
|
||||||
|
attempts: 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: new Date(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already synced → short-circuit.
|
||||||
|
if (queueRow.smartsheetRowId && queueRow.status === "synced") {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "skipped",
|
||||||
|
reason: "Already synced",
|
||||||
|
smartsheetRowId: queueRow.smartsheetRowId,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = await resolveWorkspaceToken(brandId);
|
||||||
|
if (!ws.ok) {
|
||||||
|
// "connection_disabled" is an intentional admin pause — skip
|
||||||
|
// cleanly, don't retry, don't bump attempts or log as a failure.
|
||||||
|
// (Sync services treat each reason differently per the Cycle 7
|
||||||
|
// design doc.)
|
||||||
|
if (ws.reason === "connection_disabled") {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "skipped",
|
||||||
|
reason: "workspace paused",
|
||||||
|
smartsheetRowId: queueRow?.smartsheetRowId ?? null,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const reason =
|
||||||
|
ws.reason === "no_workspace"
|
||||||
|
? "Smartsheet workbook not connected"
|
||||||
|
: `workspace token unreadable: ${ws.error}`;
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: reason,
|
||||||
|
attempts: 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: new Date(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const token = ws.token;
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
logId: logRow.log.id,
|
||||||
|
clockInIso: logRow.log.clockIn.toISOString(),
|
||||||
|
clockOutIso: logRow.log.clockOut.toISOString(),
|
||||||
|
workerName: logRow.workerName ?? "(unknown worker)",
|
||||||
|
taskName: logRow.log.taskName,
|
||||||
|
hours: hoursBetween(
|
||||||
|
logRow.log.clockIn,
|
||||||
|
logRow.log.clockOut,
|
||||||
|
logRow.log.lunchBreakMinutes,
|
||||||
|
),
|
||||||
|
lunchMinutes: logRow.log.lunchBreakMinutes,
|
||||||
|
notes: logRow.log.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cells = buildCells(config.columnMapping, values);
|
||||||
|
if (!cells) {
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: "Column mapping is missing required fields (log_id, clock_in)",
|
||||||
|
attempts: queueRow.attempts + 1,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: new Date(
|
||||||
|
Date.now() +
|
||||||
|
Math.min(MAX_BACKOFF_MINUTES, 2 ** (queueRow.attempts + 1)) *
|
||||||
|
60_000,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// Persist failure on the queue row.
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
attempts: queueRow.attempts + 1,
|
||||||
|
lastError: result.error,
|
||||||
|
nextAttemptAt: result.retryAt,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
|
||||||
|
await db.insert(timeTrackingSmartsheetSyncLog).values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
action: "retry",
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup by log_id column, then insert.
|
||||||
|
let existing = null as Awaited<ReturnType<typeof findRowByColumn>>;
|
||||||
|
try {
|
||||||
|
existing = await findRowByColumn(
|
||||||
|
config.sheetId,
|
||||||
|
token,
|
||||||
|
config.columnMapping.log_id,
|
||||||
|
values.logId,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Dedup is best-effort — log the error and continue to insert.
|
||||||
|
// If a duplicate lands in the sheet, the admin can resolve
|
||||||
|
// manually using the Recent Activity panel.
|
||||||
|
await db.insert(timeTrackingSmartsheetSyncLog).values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
action: "skip",
|
||||||
|
success: false,
|
||||||
|
error: `Dedup search failed: ${sanitizeError(
|
||||||
|
(err as Error).message ?? String(err),
|
||||||
|
token,
|
||||||
|
)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Existing row — record the dedup hit and mark the queue row synced.
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
status: "synced",
|
||||||
|
smartsheetRowId: existing.rowId,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
attempts: queueRow.attempts + 1,
|
||||||
|
lastError: null,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
|
||||||
|
await db.insert(timeTrackingSmartsheetSyncLog).values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
smartsheetRowId: existing.rowId,
|
||||||
|
action: "skip",
|
||||||
|
success: true,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "skipped",
|
||||||
|
reason: "Already present in sheet (dedup hit)",
|
||||||
|
smartsheetRowId: existing.rowId,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing row — insert.
|
||||||
|
try {
|
||||||
|
const row = await addRow(config.sheetId, token, cells);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
status: "synced",
|
||||||
|
smartsheetRowId: row.rowId,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
attempts: queueRow.attempts + 1,
|
||||||
|
lastError: null,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
|
||||||
|
await db.update(timeTrackingSmartsheetConfig).set({
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
lastSyncError: null,
|
||||||
|
}).where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
|
||||||
|
await db.insert(timeTrackingSmartsheetSyncLog).values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
smartsheetRowId: row.rowId,
|
||||||
|
action: queueRow.attempts > 0 ? "retry" : "sync",
|
||||||
|
success: true,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "synced",
|
||||||
|
smartsheetRowId: row.rowId,
|
||||||
|
attempts: queueRow.attempts + 1,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = sanitizeError(
|
||||||
|
err instanceof SmartsheetApiError
|
||||||
|
? `${err.message} (status ${err.status})`
|
||||||
|
: (err as Error).message ?? String(err),
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const newAttempts = queueRow.attempts + 1;
|
||||||
|
const retryAt = new Date(
|
||||||
|
Date.now() +
|
||||||
|
Math.min(MAX_BACKOFF_MINUTES, 2 ** newAttempts) * 60_000,
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
status: newAttempts >= DEFAULT_MAX_ATTEMPTS ? "failed" : "pending",
|
||||||
|
attempts: newAttempts,
|
||||||
|
lastError: message,
|
||||||
|
nextAttemptAt: retryAt,
|
||||||
|
})
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
|
||||||
|
await db.insert(timeTrackingSmartsheetSyncLog).values({
|
||||||
|
brandId,
|
||||||
|
logId,
|
||||||
|
action: "retry",
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
const result: TTSyncResult = {
|
||||||
|
status: "failed",
|
||||||
|
error: message,
|
||||||
|
attempts: newAttempts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drain (used by cron + manual retry) ────────────────────────────────────
|
||||||
|
|
||||||
|
export type DrainOptions = {
|
||||||
|
batchSize?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function drainTTSyncQueue(
|
||||||
|
brandId: string,
|
||||||
|
opts: DrainOptions = {},
|
||||||
|
): Promise<TTDrainResult> {
|
||||||
|
const batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
||||||
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const due = await db
|
||||||
|
.select({ id: timeTrackingSmartsheetSyncQueue.id })
|
||||||
|
.from(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeTrackingSmartsheetSyncQueue.brandId, brandId),
|
||||||
|
inArray(timeTrackingSmartsheetSyncQueue.status, [
|
||||||
|
"pending",
|
||||||
|
"failed",
|
||||||
|
]),
|
||||||
|
lte(timeTrackingSmartsheetSyncQueue.nextAttemptAt, new Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(batchSize);
|
||||||
|
|
||||||
|
const result: TTDrainResult = {
|
||||||
|
brandId,
|
||||||
|
considered: due.length,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of due) {
|
||||||
|
// Re-fetch the queue row's log_id (the outer select only had id)
|
||||||
|
const queueRows = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetSyncQueue)
|
||||||
|
.where(eq(timeTrackingSmartsheetSyncQueue.id, row.id))
|
||||||
|
.limit(1);
|
||||||
|
const queueRow = queueRows[0];
|
||||||
|
if (!queueRow) continue;
|
||||||
|
if (queueRow.attempts >= maxAttempts) {
|
||||||
|
result.failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = await syncLogToSmartsheet(brandId, queueRow.logId);
|
||||||
|
if (r.status === "synced") result.synced += 1;
|
||||||
|
else if (r.status === "skipped") result.skipped += 1;
|
||||||
|
else result.failed += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cron entrypoint ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Used by the cron route. Reads the config's frequency and decides
|
||||||
|
* whether to drain now (realtime → always; every_15 → if last drain
|
||||||
|
* was >=15m ago; hourly → if last drain >=60m ago).
|
||||||
|
*
|
||||||
|
* Returns the drain result, or `{ skipped: true, reason }` if it's
|
||||||
|
* not yet time for this brand. */
|
||||||
|
export async function runScheduledTTSync(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<TTDrainResult | { skipped: true; reason: string }> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [config] = await db
|
||||||
|
.select()
|
||||||
|
.from(timeTrackingSmartsheetConfig)
|
||||||
|
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!config) return { skipped: true, reason: "No config" };
|
||||||
|
if (!config.syncEnabled)
|
||||||
|
return { skipped: true, reason: "Sync disabled" };
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.syncFrequency !== "realtime" &&
|
||||||
|
config.lastSyncAt
|
||||||
|
) {
|
||||||
|
const elapsedMin =
|
||||||
|
(Date.now() - config.lastSyncAt.getTime()) / 60_000;
|
||||||
|
const minInterval =
|
||||||
|
config.syncFrequency === "every_15_minutes" ? 15 : 60;
|
||||||
|
if (elapsedMin < minInterval) {
|
||||||
|
return {
|
||||||
|
skipped: true,
|
||||||
|
reason: `Last sync ${Math.round(elapsedMin)}m ago; need ${minInterval}m`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return drainTTSyncQueue(brandId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress unused-import warnings (the smoke-test rationale used to
|
||||||
|
// live here but both types are reachable from the call site now).
|
||||||
|
void timeTrackingTasks;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Workspace token resolver — server-only helper.
|
||||||
|
*
|
||||||
|
* Cycle 7 — extracted from `src/actions/smartsheet/workspace.ts` to
|
||||||
|
* avoid a `"use server"` leak: every async export of a "use server"
|
||||||
|
* file is reachable as a server action RPC, and we don't want this
|
||||||
|
* function callable from the client (it returns the plaintext API
|
||||||
|
* token). Living in a regular module means it's only importable from
|
||||||
|
* other server code (`smartsheet-sync.ts`, `time-tracking-smartsheet-
|
||||||
|
* sync.ts`) — the client surface is in the workspace actions file,
|
||||||
|
* which gates with `requireManageSettings` and never returns
|
||||||
|
* plaintext.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
|
||||||
|
import { decryptToken } from "@/lib/crypto";
|
||||||
|
|
||||||
|
export type ResolvedWorkspaceToken =
|
||||||
|
| { ok: true; token: string }
|
||||||
|
| { ok: false; reason: "no_workspace" }
|
||||||
|
| { ok: false; reason: "connection_disabled" }
|
||||||
|
| { ok: false; reason: "decryption_failed"; error: string };
|
||||||
|
|
||||||
|
export async function resolveWorkspaceToken(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<ResolvedWorkspaceToken> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(smartsheetWorkspace)
|
||||||
|
.where(eq(smartsheetWorkspace.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const ws = rows[0];
|
||||||
|
if (!ws) return { ok: false, reason: "no_workspace" };
|
||||||
|
if (!ws.connectionEnabled) return { ok: false, reason: "connection_disabled" };
|
||||||
|
try {
|
||||||
|
const token = decryptToken({
|
||||||
|
cipher: ws.encryptedApiToken,
|
||||||
|
iv: ws.tokenIv,
|
||||||
|
authTag: ws.tokenAuthTag,
|
||||||
|
});
|
||||||
|
return { ok: true, token };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "decryption_failed",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `resolveWorkspaceToken` — the security-critical
|
||||||
|
* helper that returns the plaintext Smartsheet API token for a brand.
|
||||||
|
*
|
||||||
|
* Every async export of a "use server" file is reachable as a server
|
||||||
|
* action RPC, so this helper was extracted to a plain server-only
|
||||||
|
* module (cycle 7) precisely to prevent accidental client exposure.
|
||||||
|
* The 4-branch discriminated union below is the security boundary —
|
||||||
|
* if it ever leaks or collapses, tokens can leak.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { mockWithBrand, mockDecryptToken } = vi.hoisted(() => ({
|
||||||
|
mockWithBrand: vi.fn(),
|
||||||
|
mockDecryptToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub the server-only guard so the module can be imported under vitest.
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
// Mock the Drizzle client wrapper so we can simulate per-brand reads
|
||||||
|
// without hitting Postgres.
|
||||||
|
vi.mock("@/db/client", () => ({
|
||||||
|
withBrand: mockWithBrand,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the AES-256-GCM decryption layer so we can simulate bad
|
||||||
|
// ciphertext without managing real keys / IVs.
|
||||||
|
vi.mock("@/lib/crypto", () => ({
|
||||||
|
decryptToken: mockDecryptToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWithBrand.mockReset();
|
||||||
|
mockDecryptToken.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWorkspaceToken", () => {
|
||||||
|
it("returns ok:true with the plaintext token when the workspace exists, is enabled, and decrypts", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [
|
||||||
|
{
|
||||||
|
brandId: "brand-1",
|
||||||
|
encryptedApiToken: "cipher",
|
||||||
|
tokenIv: "iv",
|
||||||
|
tokenAuthTag: "tag",
|
||||||
|
connectionEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockDecryptToken.mockReturnValueOnce("plain-token-abc");
|
||||||
|
|
||||||
|
const result = await resolveWorkspaceToken("brand-1");
|
||||||
|
expect(result).toEqual({ ok: true, token: "plain-token-abc" });
|
||||||
|
expect(mockDecryptToken).toHaveBeenCalledWith({
|
||||||
|
cipher: "cipher",
|
||||||
|
iv: "iv",
|
||||||
|
authTag: "tag",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false reason:no_workspace when the workspace row does not exist", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveWorkspaceToken("brand-1");
|
||||||
|
expect(result).toEqual({ ok: false, reason: "no_workspace" });
|
||||||
|
expect(mockDecryptToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false reason:connection_disabled when the workspace is paused", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [
|
||||||
|
{
|
||||||
|
brandId: "brand-1",
|
||||||
|
encryptedApiToken: "cipher",
|
||||||
|
tokenIv: "iv",
|
||||||
|
tokenAuthTag: "tag",
|
||||||
|
connectionEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveWorkspaceToken("brand-1");
|
||||||
|
expect(result).toEqual({ ok: false, reason: "connection_disabled" });
|
||||||
|
expect(mockDecryptToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false reason:decryption_failed with the underlying error message", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [
|
||||||
|
{
|
||||||
|
brandId: "brand-1",
|
||||||
|
encryptedApiToken: "cipher",
|
||||||
|
tokenIv: "iv",
|
||||||
|
tokenAuthTag: "tag",
|
||||||
|
connectionEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockDecryptToken.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Unsupported state or unable to authenticate data");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveWorkspaceToken("brand-1");
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "decryption_failed",
|
||||||
|
error: "Unsupported state or unable to authenticate data",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps non-Error thrown values in a string for decryption_failed", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [
|
||||||
|
{
|
||||||
|
brandId: "brand-1",
|
||||||
|
encryptedApiToken: "cipher",
|
||||||
|
tokenIv: "iv",
|
||||||
|
tokenAuthTag: "tag",
|
||||||
|
connectionEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockDecryptToken.mockImplementationOnce(() => {
|
||||||
|
throw "string-throw-not-error";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveWorkspaceToken("brand-1");
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "decryption_failed",
|
||||||
|
error: "string-throw-not-error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads the brandId through to withBrand", async () => {
|
||||||
|
mockWithBrand.mockImplementationOnce(
|
||||||
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolveWorkspaceToken("the-right-brand");
|
||||||
|
expect(mockWithBrand).toHaveBeenCalledWith(
|
||||||
|
"the-right-brand",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `src/services/sync-drain-summary.ts` — the per-brand
|
||||||
|
* drain translator that the cycle-8 cron route delegates to.
|
||||||
|
*
|
||||||
|
* The interesting case here is the discriminated union narrowing:
|
||||||
|
* `runScheduledTTSync` returns either a regular `TTDrainResult`
|
||||||
|
* (counters, `skipped: number`) or `{ skipped: true, reason }`. Both
|
||||||
|
* branches carry a `skipped` property, so narrowing on the literal
|
||||||
|
* `result.skipped === true` is the only correct disambiguation.
|
||||||
|
*
|
||||||
|
* Regression coverage: if narrowing switched to `"skipped" in result`,
|
||||||
|
* the happy-branch test (lines 30-44) would fail because
|
||||||
|
* `{ skipped: 1, ... }` would misroute into the if-branch and
|
||||||
|
* `result.reason` would be undefined.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
drainOneBrand,
|
||||||
|
summarizeDrains,
|
||||||
|
type DrainRunner,
|
||||||
|
} from "@/services/sync-drain-summary";
|
||||||
|
|
||||||
|
describe("drainOneBrand", () => {
|
||||||
|
it("forwards a regular TTDrainResult as a flat DrainSummary", async () => {
|
||||||
|
const run: DrainRunner = vi.fn(async (brandId) => ({
|
||||||
|
brandId,
|
||||||
|
considered: 5,
|
||||||
|
synced: 3,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = await drainOneBrand("brand-1", run);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
brandId: "brand-1",
|
||||||
|
considered: 5,
|
||||||
|
synced: 3,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
expect(summary.skippedReason).toBeUndefined();
|
||||||
|
expect(summary.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates the { skipped: true, reason } branch into a zero summary with skippedReason", async () => {
|
||||||
|
const run: DrainRunner = vi.fn(async () => ({
|
||||||
|
skipped: true as const,
|
||||||
|
reason: "Last sync 3m ago; need 15m",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = await drainOneBrand("brand-1", run);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
brandId: "brand-1",
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
skippedReason: "Last sync 3m ago; need 15m",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a thrown Error and surfaces it as `error` (does not reject)", async () => {
|
||||||
|
const run: DrainRunner = vi.fn(async () => {
|
||||||
|
throw new Error("Smartsheet 401");
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = await drainOneBrand("brand-1", run);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
brandId: "brand-1",
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
error: "Smartsheet 401",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces non-Error thrown values to a string", async () => {
|
||||||
|
const run: DrainRunner = vi.fn(async () => {
|
||||||
|
throw "string-failure";
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = await drainOneBrand("brand-1", run);
|
||||||
|
expect(summary.error).toBe("string-failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads brandId to the runner", async () => {
|
||||||
|
const run: DrainRunner = vi.fn(async () => ({
|
||||||
|
brandId: "ignored",
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
}));
|
||||||
|
await drainOneBrand("the-real-brand", run);
|
||||||
|
expect(run).toHaveBeenCalledWith("the-real-brand");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeDrains", () => {
|
||||||
|
it("sums every counter across all per-brand summaries", () => {
|
||||||
|
const totals = summarizeDrains([
|
||||||
|
{
|
||||||
|
brandId: "a",
|
||||||
|
considered: 5,
|
||||||
|
synced: 3,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brandId: "b",
|
||||||
|
considered: 4,
|
||||||
|
synced: 2,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 1,
|
||||||
|
skippedReason: "Not due",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brandId: "c",
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
error: "401",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(totals).toEqual({
|
||||||
|
considered: 9,
|
||||||
|
synced: 5,
|
||||||
|
skipped: 2,
|
||||||
|
failed: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zeros for an empty batch", () => {
|
||||||
|
expect(summarizeDrains([])).toEqual({
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the cycle-8 cron route at
|
||||||
|
* `src/app/api/time-tracking/smartsheet-sync/route.ts`.
|
||||||
|
*
|
||||||
|
* The route is intentionally thin: authenticate → parse body → fan
|
||||||
|
* out per brand → aggregate. These tests mock the three external
|
||||||
|
* dependencies (withPlatformAdmin, timeTrackingSmartsheetConfig, and
|
||||||
|
* runScheduledTTSync) and exercise:
|
||||||
|
*
|
||||||
|
* - auth (Bearer header, ?secret query, missing-in-prod, allow-in-dev)
|
||||||
|
* - body parsing (optional, malformed JSON doesn't crash)
|
||||||
|
* - body.brandId restricts to one brand; omitted fans out to all
|
||||||
|
* - success flag is false when any brand has failures
|
||||||
|
*
|
||||||
|
* The pure per-brand drain logic is tested separately in
|
||||||
|
* `sync-drain-summary.test.ts`.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockWithPlatformAdmin,
|
||||||
|
mockRunScheduledTTSync,
|
||||||
|
mockDrainOneBrand,
|
||||||
|
mockSummarizeDrains,
|
||||||
|
mockSelect,
|
||||||
|
mockFrom,
|
||||||
|
mockWhere,
|
||||||
|
mockLimit,
|
||||||
|
mockTimeTrackingSmartsheetConfig,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockFrom = vi.fn();
|
||||||
|
const mockWhere = vi.fn();
|
||||||
|
const mockLimit = vi.fn();
|
||||||
|
const mockTimeTrackingSmartsheetConfig = { brandId: "fake_brand_id_col" };
|
||||||
|
return {
|
||||||
|
mockWithPlatformAdmin: vi.fn(),
|
||||||
|
mockRunScheduledTTSync: vi.fn(),
|
||||||
|
mockDrainOneBrand: vi.fn(),
|
||||||
|
mockSummarizeDrains: vi.fn(),
|
||||||
|
mockSelect,
|
||||||
|
mockFrom,
|
||||||
|
mockWhere,
|
||||||
|
mockLimit,
|
||||||
|
mockTimeTrackingSmartsheetConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
vi.mock("@/db/client", () => ({
|
||||||
|
withPlatformAdmin: mockWithPlatformAdmin,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/db/schema/time-tracking", () => ({
|
||||||
|
timeTrackingSmartsheetConfig: mockTimeTrackingSmartsheetConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/services/time-tracking-smartsheet-sync", () => ({
|
||||||
|
runScheduledTTSync: mockRunScheduledTTSync,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/services/sync-drain-summary", () => ({
|
||||||
|
drainOneBrand: mockDrainOneBrand,
|
||||||
|
summarizeDrains: mockSummarizeDrains,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import the route AFTER all mocks are wired up.
|
||||||
|
import { POST, GET } from "@/app/api/time-tracking/smartsheet-sync/route";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
function fakeNextRequest({
|
||||||
|
url = "http://localhost/api/time-tracking/smartsheet-sync",
|
||||||
|
method = "POST",
|
||||||
|
headers = {},
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} = {}) {
|
||||||
|
return new NextRequest(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCronSecret(secret: string | undefined, nodeEnv = "test") {
|
||||||
|
// vi.stubEnv handles the typed-readonly quirk on NODE_ENV for us
|
||||||
|
// and restores the previous value on vi.unstubAllEnvs().
|
||||||
|
vi.stubEnv("NODE_ENV", nodeEnv);
|
||||||
|
if (secret === undefined) {
|
||||||
|
vi.stubEnv("SMARTSHEET_CRON_SECRET", "");
|
||||||
|
vi.stubEnv("CRON_SECRET", "");
|
||||||
|
} else {
|
||||||
|
vi.stubEnv("SMARTSHEET_CRON_SECRET", secret);
|
||||||
|
vi.stubEnv("CRON_SECRET", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockWithPlatformAdmin.mockReset();
|
||||||
|
mockRunScheduledTTSync.mockReset();
|
||||||
|
mockDrainOneBrand.mockReset();
|
||||||
|
mockSummarizeDrains.mockReset();
|
||||||
|
mockSelect.mockReset();
|
||||||
|
mockFrom.mockReset();
|
||||||
|
mockWhere.mockReset();
|
||||||
|
mockLimit.mockReset();
|
||||||
|
|
||||||
|
// Default chain: select → from → where → (thenable rows)
|
||||||
|
// The route's listActiveBrandIds does `db.select(...).from(...).where(...)`
|
||||||
|
// and awaits the result directly (no `.limit()`), so the where()
|
||||||
|
// call has to resolve to an array of rows.
|
||||||
|
const defaultRows = [{ id: "brand-a" }, { id: "brand-b" }];
|
||||||
|
mockWhere.mockImplementation(() => Promise.resolve(defaultRows));
|
||||||
|
mockFrom.mockReturnValue({ where: mockWhere });
|
||||||
|
mockSelect.mockReturnValue({ from: mockFrom });
|
||||||
|
|
||||||
|
mockWithPlatformAdmin.mockImplementation(
|
||||||
|
async (fn: (db: unknown) => Promise<unknown>) =>
|
||||||
|
fn({ select: mockSelect }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default behavior: pretend every brand skipped (so we can assert
|
||||||
|
// per-brand delegation works without coupling to drainOneBrand's
|
||||||
|
// own logic).
|
||||||
|
mockDrainOneBrand.mockImplementation(async (brandId: string) => ({
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
skippedReason: "no-op",
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockSummarizeDrains.mockReturnValue({
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
// Re-apply ORIGINAL_ENV in case anything outside vi.stubEnv mutated it
|
||||||
|
// (defense-in-depth; the production code only reads via process.env).
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth", () => {
|
||||||
|
it("rejects with 401 when CRON_SECRET is set and no token is presented in prod", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await POST(fakeNextRequest({}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(mockDrainOneBrand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 401 when the Bearer header does not match", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer wrong-token" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a matching Bearer header in prod", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a matching ?secret query in prod", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
url: "http://localhost/api/time-tracking/smartsheet-sync?secret=the-secret",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CRON_SECRET when SMARTSHEET_CRON_SECRET is unset", async () => {
|
||||||
|
vi.stubEnv("SMARTSHEET_CRON_SECRET", "");
|
||||||
|
vi.stubEnv("CRON_SECRET", "fallback-secret");
|
||||||
|
// NODE_ENV is typed as readonly on the index signature; mutate
|
||||||
|
// through Object.assign so the test reads the new value while the
|
||||||
|
// TS compiler stays quiet.
|
||||||
|
Object.assign(process.env, { NODE_ENV: "production" });
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer fallback-secret" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows unauthenticated calls in non-production when no secret is configured", async () => {
|
||||||
|
setCronSecret(undefined, "development");
|
||||||
|
const res = await POST(fakeNextRequest({}));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body parsing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fans out to every active brand when no body is supplied", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Two rows came back from the limit() chain → two brands drained
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"brand-a",
|
||||||
|
mockRunScheduledTTSync,
|
||||||
|
);
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"brand-b",
|
||||||
|
mockRunScheduledTTSync,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restricts to the requested brandId when supplied", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
body: JSON.stringify({ brandId: "brand-only" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenCalledWith(
|
||||||
|
"brand-only",
|
||||||
|
mockRunScheduledTTSync,
|
||||||
|
);
|
||||||
|
// Should NOT have queried the DB for the active brand list
|
||||||
|
expect(mockWithPlatformAdmin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a malformed JSON body (treats as no body)", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
body: "this-is-not-json",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// No body.brandId → fans out to active brands
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aggregation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success:true and aggregates per-brand summaries", async () => {
|
||||||
|
mockDrainOneBrand.mockImplementation(async (brandId: string) => ({
|
||||||
|
brandId,
|
||||||
|
considered: 2,
|
||||||
|
synced: 1,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 0,
|
||||||
|
}));
|
||||||
|
mockSummarizeDrains.mockReturnValue({
|
||||||
|
considered: 2,
|
||||||
|
synced: 1,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
body: JSON.stringify({ brandId: "brand-only" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.totals).toEqual({
|
||||||
|
considered: 2,
|
||||||
|
synced: 1,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
expect(body.results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success:false when any brand has a failure", async () => {
|
||||||
|
mockSummarizeDrains.mockReturnValue({
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
fakeNextRequest({
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
body: JSON.stringify({ brandId: "brand-only" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.totals.failed).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET", () => {
|
||||||
|
it("delegates to POST (same auth + body handling)", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await GET(
|
||||||
|
fakeNextRequest({
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: "Bearer the-secret" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockDrainOneBrand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 on GET when auth fails", async () => {
|
||||||
|
setCronSecret("the-secret", "production");
|
||||||
|
const res = await GET(fakeNextRequest({ method: "GET" }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
"path": "/api/water-log/smartsheet-sync",
|
"path": "/api/water-log/smartsheet-sync",
|
||||||
"schedule": "*/15 * * * *"
|
"schedule": "*/15 * * * *"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/time-tracking/smartsheet-sync",
|
||||||
|
"schedule": "*/15 * * * *"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/api/email-automation/abandoned-cart",
|
"path": "/api/email-automation/abandoned-cart",
|
||||||
"schedule": "0 */6 * * *"
|
"schedule": "0 */6 * * *"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
|||||||
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
||||||
// Specific sub-paths must come before the general @/db catch-all.
|
// Specific sub-paths must come before the general @/db catch-all.
|
||||||
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
|
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
|
||||||
|
{ find: "@/db/schema/smartsheet-workspace", replacement: path.resolve(__dirname, "db/schema/smartsheet-workspace.ts") },
|
||||||
|
{ find: "@/db/schema/time-tracking", replacement: path.resolve(__dirname, "db/schema/time-tracking.ts") },
|
||||||
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
||||||
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
||||||
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
||||||
|
|||||||
Reference in New Issue
Block a user