Compare commits
9 Commits
bf68fa8431
...
ce652ff5de
| 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 {
|
||||
pgTable,
|
||||
@@ -10,6 +15,7 @@ import {
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
@@ -159,3 +165,145 @@ export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
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,
|
||||
uniqueIndex,
|
||||
integer,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
|
||||
// Drizzle's stock `bytea` import is not exported in every version, so
|
||||
// register a small custom type that maps to the existing `bytea` PG type.
|
||||
const bytea = customType<{ data: Buffer; default: false }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
// Cycle 7: the `bytea` customType used to live here for the
|
||||
// encrypted Smartsheet token columns. Those columns moved to
|
||||
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
|
||||
// so the helper is no longer needed here.
|
||||
|
||||
export const waterHeadgates = pgTable(
|
||||
"water_headgates",
|
||||
@@ -323,9 +319,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
sheetId: text("sheet_id").notNull(),
|
||||
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
||||
tokenIv: bytea("token_iv").notNull(),
|
||||
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
||||
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
|
||||
// See migration 0096_smartsheet_workbook_hub.sql.
|
||||
columnMapping: jsonb("column_mapping")
|
||||
.$type<SmartsheetColumnMapping>()
|
||||
.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";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
// TODO(migration): the time-tracking feature was built on Supabase RPCs
|
||||
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
|
||||
// get_open_clock_in, get_time_tracking_tasks,
|
||||
// get_worker_pay_period_hours, get_time_tracking_settings,
|
||||
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
|
||||
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
|
||||
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
|
||||
// functions below are stubs that preserve the original signature so
|
||||
// the field UI degrades gracefully (no PIN login, no entry submit) —
|
||||
// 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.
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
timeTrackingWorkers,
|
||||
timeTrackingTasks,
|
||||
timeTrackingLogs,
|
||||
timeTrackingSmartsheetSyncQueue,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { verifyPin } from "@/lib/water-log-pin";
|
||||
import { getWorkerPeriodTotals } from "./index";
|
||||
|
||||
export type TimeTrackingSession = {
|
||||
worker_id: string;
|
||||
@@ -26,75 +43,292 @@ export type TimeTrackingSession = {
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
// ── Cookie helpers ─────────────────────────────────────────────────────────────
|
||||
// ── Cookie helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
|
||||
function sessionCookieValue(session: TimeTrackingSession): string {
|
||||
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("|");
|
||||
if (parts.length !== 3) return null;
|
||||
if (parts.length !== 7) return null;
|
||||
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;
|
||||
return {
|
||||
worker_id,
|
||||
name: "",
|
||||
role: "worker",
|
||||
lang: "en",
|
||||
session_id,
|
||||
brand_id: "",
|
||||
expires_at,
|
||||
};
|
||||
return { worker_id, session_id, expires_at };
|
||||
}
|
||||
|
||||
// ── Verify PIN ─────────────────────────────────────────────────────────────────
|
||||
// ── Verify PIN ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function verifyTimeTrackingPin(
|
||||
_brandId: string,
|
||||
_pin: string
|
||||
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
|
||||
|
||||
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can
|
||||
// disable the PIN pad.
|
||||
return { success: false, error: "Time tracking is not configured" };
|
||||
brandId: string,
|
||||
pin: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
session?: TimeTrackingSession;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!pin || pin.length < 4) {
|
||||
return { success: false, error: "Enter your 4-digit PIN." };
|
||||
}
|
||||
|
||||
// ── Clock In ───────────────────────────────────────────────────────────────────
|
||||
// Pull every active worker for the brand and try each PIN hash. With
|
||||
// a small worker pool this is fine; if it grows we could index a
|
||||
// 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clockInWorker(
|
||||
_brandId: string,
|
||||
_taskId?: string,
|
||||
_taskName = "General Labor"
|
||||
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
|
||||
|
||||
await getSession();
|
||||
brandId: string,
|
||||
taskId?: string,
|
||||
taskName = "General Labor",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
log_id?: string;
|
||||
clock_in?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await getTimeTrackingSession();
|
||||
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" };
|
||||
}
|
||||
|
||||
// ── Clock Out ──────────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
_lunchMinutes = 0,
|
||||
_notes?: string
|
||||
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
|
||||
|
||||
await getSession();
|
||||
lunchMinutes = 0,
|
||||
notes?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
log_id?: string;
|
||||
clock_out?: string;
|
||||
total_minutes?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await getTimeTrackingSession();
|
||||
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(
|
||||
_brandId: string
|
||||
): Promise<{
|
||||
export async function getOpenClockIn(brandId: string): Promise<{
|
||||
success: boolean;
|
||||
open?: boolean;
|
||||
log_id?: string;
|
||||
@@ -103,34 +337,76 @@ export async function getOpenClockIn(
|
||||
elapsed_minutes?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
|
||||
await getSession();
|
||||
const session = await getTimeTrackingSession();
|
||||
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> {
|
||||
|
||||
await getSession();
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
// ── Get Current Session ────────────────────────────────────────────────────────
|
||||
// ── Get Current Session ────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
||||
|
||||
await getSession();
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(SESSION_COOKIE);
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -142,14 +418,28 @@ export type TimeTaskField = {
|
||||
};
|
||||
|
||||
export async function getTimeTrackingTasksField(
|
||||
_brandId: string,
|
||||
_activeOnly = true
|
||||
brandId: string,
|
||||
activeOnly = true,
|
||||
): Promise<TimeTaskField[]> {
|
||||
|
||||
await getSession(); return [];
|
||||
return withBrand(brandId, async (db) => {
|
||||
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 = {
|
||||
success: boolean;
|
||||
@@ -184,11 +474,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
|
||||
});
|
||||
|
||||
export async function getWorkerPayPeriodHours(
|
||||
_brandId: string
|
||||
brandId: string,
|
||||
): Promise<PayPeriodHours> {
|
||||
|
||||
await getSession();
|
||||
const session = await getTimeTrackingSession();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+622
-114
@@ -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";
|
||||
|
||||
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
|
||||
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,
|
||||
// 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 ─────────────────────────────────────────────────────────────────────
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeWorker = {
|
||||
id: string;
|
||||
@@ -25,6 +37,7 @@ export type TimeWorker = {
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
|
||||
pin: string;
|
||||
active: boolean;
|
||||
last_used_at: string | null;
|
||||
@@ -39,10 +52,13 @@ export type TimeTask = {
|
||||
unit: string;
|
||||
active: boolean;
|
||||
sort_order: number;
|
||||
brand_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimeLog = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
task_id: string | null;
|
||||
@@ -57,147 +73,465 @@ export type TimeLog = {
|
||||
};
|
||||
|
||||
export type TimeSummary = {
|
||||
by_worker: { 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 }[];
|
||||
by_worker: {
|
||||
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 };
|
||||
};
|
||||
|
||||
// ── 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.
|
||||
return [];
|
||||
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
|
||||
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(
|
||||
_brandId: string,
|
||||
_name: string,
|
||||
_role = "worker",
|
||||
_lang = "en"
|
||||
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
brandId: string,
|
||||
name: string,
|
||||
role: string = "worker",
|
||||
lang: string = "en",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
worker?: TimeWorker;
|
||||
pin?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const adminUser = await getAdminUser();
|
||||
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'" };
|
||||
}
|
||||
|
||||
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const pin = generatePin();
|
||||
const pinHash = hashPin(pin);
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
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 }> {
|
||||
const adminUser = await getAdminUser();
|
||||
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(
|
||||
_workerId: string,
|
||||
_name: string,
|
||||
_role: string,
|
||||
_lang: string,
|
||||
_active: boolean
|
||||
workerId: string,
|
||||
name: string,
|
||||
role: string,
|
||||
lang: string,
|
||||
active: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
}
|
||||
|
||||
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
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;
|
||||
});
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
return { success: false, error: "Time tracking is not configured" };
|
||||
if (!result) return { success: false, error: "Worker not found" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||
export async function deleteTimeWorker(
|
||||
workerId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
|
||||
const result = await withPlatformAdmin(async (db) => {
|
||||
const [row] = await db
|
||||
.delete(timeTrackingWorkers)
|
||||
.where(eq(timeTrackingWorkers.id, workerId))
|
||||
.returning({ id: timeTrackingWorkers.id });
|
||||
return row;
|
||||
});
|
||||
|
||||
await getSession(); return [];
|
||||
if (!result) return { success: false, error: "Worker not found" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Tasks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingTasks(
|
||||
brandId: string,
|
||||
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(
|
||||
_brandId: string,
|
||||
_name: string,
|
||||
_nameEs: string | null = null,
|
||||
_unit = "hours",
|
||||
_sortOrder = 0
|
||||
brandId: string,
|
||||
name: string,
|
||||
nameEs: string | null = null,
|
||||
unit: string = "hours",
|
||||
sortOrder = 0,
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
const adminUser = await getAdminUser();
|
||||
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(
|
||||
_taskId: string,
|
||||
_name: string,
|
||||
_nameEs: string,
|
||||
_unit: string,
|
||||
_active: boolean,
|
||||
_sortOrder: number
|
||||
taskId: string,
|
||||
name: string,
|
||||
nameEs: string,
|
||||
unit: string,
|
||||
active: boolean,
|
||||
sortOrder: number,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
}
|
||||
|
||||
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
|
||||
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;
|
||||
});
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
return { success: false, error: "Time tracking is not configured" };
|
||||
if (!result) return { success: false, error: "Task not found" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
||||
export async function deleteTimeTask(
|
||||
taskId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
export async function getWorkerTimeLogs(
|
||||
_brandId: string,
|
||||
_options: {
|
||||
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 (read-only — writes happen via field actions) ─────────────────
|
||||
|
||||
type LogListOptions = {
|
||||
workerId?: string;
|
||||
taskId?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
start?: string; // ISO timestamp
|
||||
end?: string; // ISO timestamp
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
};
|
||||
|
||||
export async function getWorkerTimeLogs(
|
||||
brandId: string,
|
||||
options: LogListOptions = {},
|
||||
): 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(
|
||||
_logId: string,
|
||||
_taskName: string,
|
||||
_clockIn: string,
|
||||
_clockOut: string | null,
|
||||
_lunchMinutes: number,
|
||||
_notes: string | null
|
||||
): 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" };
|
||||
}
|
||||
|
||||
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" };
|
||||
function computeTotalMinutes(
|
||||
clockIn: Date,
|
||||
clockOut: Date | null,
|
||||
lunchMinutes: number,
|
||||
): number {
|
||||
if (!clockOut) return 0;
|
||||
const ms = clockOut.getTime() - clockIn.getTime();
|
||||
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
|
||||
}
|
||||
|
||||
export async function getTimeTrackingSummary(
|
||||
_brandId: string,
|
||||
_start: string,
|
||||
_end: string
|
||||
brandId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
): 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;
|
||||
}
|
||||
|
||||
// ── Time Tracking Settings ─────────────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
export type TimeTrackingSettings = {
|
||||
id: string;
|
||||
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
|
||||
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.
|
||||
return null;
|
||||
export async function getTimeTrackingSettings(
|
||||
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(
|
||||
_brandId: string,
|
||||
_settings: {
|
||||
brandId: string,
|
||||
settings: {
|
||||
pay_period_start_day: number;
|
||||
pay_period_length_days: number;
|
||||
daily_overtime_threshold: number;
|
||||
weekly_overtime_threshold: number;
|
||||
overtime_multiplier: number;
|
||||
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_sms_numbers?: string[];
|
||||
enable_daily_alerts?: boolean;
|
||||
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
|
||||
weekly_alert_threshold?: number;
|
||||
send_end_of_period_summary?: boolean;
|
||||
brand_name?: string;
|
||||
}
|
||||
},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
const adminUser = await getAdminUser();
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
|
||||
};
|
||||
|
||||
export async function getTimeTrackingNotificationLog(
|
||||
_brandId: string,
|
||||
_limit = 100
|
||||
brandId: string,
|
||||
limit = 100,
|
||||
): 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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
|
||||
// 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.
|
||||
import { withBrand } from "@/db/client";
|
||||
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
|
||||
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
|
||||
|
||||
export type OvertimeCheckResult = {
|
||||
sent: boolean;
|
||||
trigger_type?: string;
|
||||
trigger_type?: "daily" | "weekly" | "both";
|
||||
message?: string;
|
||||
notification_log_id?: string;
|
||||
daily_hours?: number;
|
||||
weekly_hours?: number;
|
||||
};
|
||||
|
||||
export async function checkAndNotifyOvertime(
|
||||
_brandId: string,
|
||||
_workerId: string,
|
||||
_workerName: string,
|
||||
_dailyHours: number,
|
||||
_weeklyHours: number
|
||||
brandId: string,
|
||||
workerId: string,
|
||||
workerName: string,
|
||||
// The two hour args are accepted for back-compat with the original
|
||||
// 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> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { sent: false, message: "Not authenticated" };
|
||||
|
||||
await getSession(); const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { sent: false, message: "Not authenticated" };
|
||||
const settings = await getTimeTrackingSettings(brandId);
|
||||
if (!settings || !settings.overtime_notifications) {
|
||||
return { sent: false, message: "Notifications disabled" };
|
||||
}
|
||||
|
||||
// Re-derive totals from the source of truth so the notification log
|
||||
// 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: false,
|
||||
message: "Time tracking is not configured in the SaaS rebuild",
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* hook in `field.ts`. Permission model mirrors the rest of the water
|
||||
* log module: only admins with `can_manage_water_log` (or
|
||||
@@ -10,10 +15,9 @@
|
||||
*
|
||||
* 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.
|
||||
* - `resolveWorkspaceToken` decrypts it locally for API calls.
|
||||
* - The masked preview is fetched from the workspace (not the
|
||||
* per-feature config).
|
||||
*/
|
||||
import { desc, eq, and, gte, inArray } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
@@ -28,7 +32,8 @@ import {
|
||||
type SmartsheetColumnMapping,
|
||||
type SmartsheetFrequency,
|
||||
} 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 { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
|
||||
|
||||
export type SaveSmartsheetConfigInput = {
|
||||
sheetId: string;
|
||||
/** Plaintext token. Empty string = keep the saved token. */
|
||||
token: string;
|
||||
/** Cycle 7: token is owned by the workspace; this field is kept for
|
||||
* backward compat with the existing UI but ignored on save. */
|
||||
token?: string;
|
||||
syncEnabled: boolean;
|
||||
syncFrequency: SmartsheetFrequency;
|
||||
columnMapping: SmartsheetColumnMapping;
|
||||
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.limit(1);
|
||||
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) {
|
||||
return {
|
||||
configured: false,
|
||||
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
|
||||
syncEnabled: false,
|
||||
syncFrequency: "hourly",
|
||||
columnMapping: null,
|
||||
maskedToken: null,
|
||||
maskedToken: workspace.maskedToken,
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
hasToken: false,
|
||||
hasToken: workspace.hasToken,
|
||||
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 {
|
||||
configured: true,
|
||||
sheetId: config.sheetId,
|
||||
syncEnabled: config.syncEnabled,
|
||||
syncFrequency: config.syncFrequency,
|
||||
columnMapping: config.columnMapping,
|
||||
maskedToken: masked,
|
||||
maskedToken: workspace.maskedToken,
|
||||
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
|
||||
lastSyncError: config.lastSyncError,
|
||||
hasToken,
|
||||
hasToken: workspace.hasToken,
|
||||
updatedAt: config.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
|
||||
if (!mappingResult.ok) {
|
||||
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.
|
||||
const tokenTrimmed = input.token?.trim() ?? "";
|
||||
|
||||
// Cycle 7: the API token is owned by the workspace, not by this
|
||||
// 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) => {
|
||||
// ── Load existing to know if we have a token already ──
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(waterSmartsheetConfig)
|
||||
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
|
||||
.limit(1);
|
||||
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> = {
|
||||
sheetId: normalizedSheetId,
|
||||
syncEnabled: input.syncEnabled,
|
||||
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
|
||||
columnMapping: input.columnMapping,
|
||||
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.
|
||||
const [upserted] = await db
|
||||
.insert(waterSmartsheetConfig)
|
||||
.values({
|
||||
if (existing) {
|
||||
await db
|
||||
.update(waterSmartsheetConfig)
|
||||
.set(updateValues)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId));
|
||||
} else {
|
||||
await db.insert(waterSmartsheetConfig).values({
|
||||
brandId,
|
||||
sheetId: normalizedSheetId,
|
||||
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
|
||||
tokenIv: existing?.tokenIv ?? Buffer.from([]),
|
||||
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
|
||||
columnMapping: input.columnMapping,
|
||||
syncEnabled: input.syncEnabled,
|
||||
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,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: waterSmartsheetConfig.brandId,
|
||||
set: updateValues,
|
||||
})
|
||||
.returning();
|
||||
if (!upserted) return { success: false, error: "Save failed" };
|
||||
});
|
||||
}
|
||||
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
|
||||
sheetId: normalizedSheetId,
|
||||
syncEnabled: input.syncEnabled,
|
||||
syncFrequency: input.syncFrequency,
|
||||
tokenRotated: tokenTrimmed.length > 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-read for a clean response.
|
||||
return {
|
||||
success: true,
|
||||
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
|
||||
* for the "Test Connection" button on a fresh form).
|
||||
* - If `token` is empty and a token is saved, decrypt + test that.
|
||||
* - Returns the sheet name + columns on success.
|
||||
* - If `token` is non-empty, it's used directly (test the hub card
|
||||
* before saving).
|
||||
* - If `token` is empty, the workspace token is decrypted and used.
|
||||
*
|
||||
* 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
|
||||
* UI can show a helpful message.
|
||||
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
|
||||
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() ?? "";
|
||||
if (!token) {
|
||||
const saved = await withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
});
|
||||
if (!saved) {
|
||||
return { success: false, error: "No token saved yet — paste one above to test." };
|
||||
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 above." };
|
||||
}
|
||||
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." };
|
||||
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 };
|
||||
}
|
||||
token = ws.token;
|
||||
}
|
||||
|
||||
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 { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() {
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
<WaterLogAdminPanel
|
||||
<WaterLogShell
|
||||
initialUsers={users}
|
||||
initialHeadgates={headgates}
|
||||
initialEntries={entries}
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
regenerateAdminPin,
|
||||
type AdminSettings,
|
||||
} from "@/actions/water-log/settings";
|
||||
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
|
||||
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";
|
||||
|
||||
@@ -475,8 +477,10 @@ export default function WaterLogSettingsPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Smartsheet Integration — independent of the Admin Portal toggle above.
|
||||
Self-contained card with its own save / test / activity surface. */}
|
||||
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
|
||||
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">
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
||||
§ 05 — Integrations
|
||||
@@ -488,14 +492,31 @@ export default function WaterLogSettingsPage() {
|
||||
Smartsheet
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||
Push every new water log entry to a Smartsheet sheet. The
|
||||
brand admin (you) controls the connection, frequency, and
|
||||
column mapping. The token is encrypted at rest.
|
||||
Connect one Smartsheet workbook and pick which sheet each
|
||||
feature writes to. The token is encrypted at rest; per-feature
|
||||
cards below manage only their sheet mapping.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
|
||||
]);
|
||||
allWorkers.push(workers);
|
||||
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) {
|
||||
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 { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
|
||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
const BRAND_ACCENT = "green";
|
||||
import {
|
||||
TUXEDO_BRAND_ID,
|
||||
TUXEDO_BRAND_NAME,
|
||||
TUXEDO_BRAND_ACCENT,
|
||||
} from "@/lib/water-log/brand";
|
||||
|
||||
export const metadata = {
|
||||
title: "Worker Clock — Tuxedo Corn",
|
||||
title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
|
||||
};
|
||||
|
||||
export default async function TuxedoTimeClockPage() {
|
||||
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
|
||||
|
||||
return (
|
||||
<TimeTrackingFieldClient
|
||||
brandId={BRAND_ID}
|
||||
brandName={BRAND_NAME}
|
||||
brandAccent={BRAND_ACCENT}
|
||||
brandId={TUXEDO_BRAND_ID}
|
||||
brandName={TUXEDO_BRAND_NAME}
|
||||
brandAccent={TUXEDO_BRAND_ACCENT}
|
||||
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.
|
||||
*
|
||||
* Flow:
|
||||
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
|
||||
* ▲ │ │
|
||||
* │ └───────────────(logout)─────────────────┤
|
||||
* └─(logout)─────(anywhere)──────────────────────────────┘
|
||||
* pin ─verify─▶ tabbed shell
|
||||
* │
|
||||
* success ── Log Another ─▶ log (cleared) ─┘
|
||||
* success ── Back to Headgates ─▶ headgates
|
||||
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
|
||||
* │ ▲ │
|
||||
* │ │ │
|
||||
* │ └───────── (logout) ────────────┤
|
||||
* │
|
||||
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
|
||||
*
|
||||
* Hydration:
|
||||
* - 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
|
||||
* 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:
|
||||
* - `verifyWaterPin` → signs the irrigator in, sets cookie
|
||||
* - `getWaterHeadgates` → loads the list of active gates
|
||||
* - `submitWaterEntry` → posts a new reading
|
||||
* - `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 {
|
||||
@@ -34,13 +43,28 @@ import {
|
||||
getWaterHeadgates,
|
||||
logoutWater,
|
||||
} 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 { MobileWaterHeadgateList } from "./HeadgateList";
|
||||
import { MobileWaterLogForm } from "./LogForm";
|
||||
import { MobileWaterSuccessScreen } from "./SuccessScreen";
|
||||
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";
|
||||
|
||||
// Server-side auth errors that should bounce the user to the PIN
|
||||
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
|
||||
|
||||
export function MobileWaterApp() {
|
||||
const [screen, setScreen] = useState<Screen>("pin");
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
|
||||
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
|
||||
// so it's always up-to-date when the user toggles via the toggle
|
||||
// in the headgate list header.
|
||||
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
|
||||
setIrrigatorName(result.name ?? null);
|
||||
setScreen("headgates");
|
||||
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],
|
||||
);
|
||||
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
|
||||
|
||||
// ── Logout (from anywhere) ────────────────────────────────────
|
||||
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 {
|
||||
await logoutWater();
|
||||
} catch {
|
||||
// Even if the server logout fails, clear local state so the
|
||||
// user isn't stuck.
|
||||
}
|
||||
try {
|
||||
await logoutTimeTracking();
|
||||
} catch {
|
||||
// Same — defensive cleanup.
|
||||
}
|
||||
setIrrigatorName(null);
|
||||
setHeadgates([]);
|
||||
setSelectedHeadgate(null);
|
||||
setSubmittedLog(null);
|
||||
setActiveTab("headgates");
|
||||
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 ──────────────────────────────
|
||||
// Compute the screen element first, then wrap it once in
|
||||
// <LangProvider>. Wrapping per-case would remount the provider on
|
||||
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
|
||||
/>
|
||||
);
|
||||
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
|
||||
// WaterFieldClient — we just return the screen element here.
|
||||
return screenElement;
|
||||
// ── Post-PIN shell ────────────────────────────────────────────
|
||||
// The pre-PIN screen is bare (no nav). After PIN we wrap the
|
||||
// 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;
|
||||
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
|
||||
return unit === "holes";
|
||||
}
|
||||
|
||||
/** Top-level screen identifiers — drives the state machine. */
|
||||
export type Screen = "pin" | "headgates" | "log" | "success";
|
||||
/** Top-level screen identifiers — drives the state machine.
|
||||
*
|
||||
* 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). */
|
||||
export type SubmittedLog = {
|
||||
|
||||
@@ -12,3 +12,12 @@
|
||||
* which is the standard pattern used throughout the rest of the app.
|
||||
*/
|
||||
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;
|
||||
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> = {
|
||||
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
minutesAgo: (n) => `${n}m ago`,
|
||||
hoursAgo: (n) => `${n}h ago`,
|
||||
},
|
||||
tab: {
|
||||
headgates: "Headgates",
|
||||
time: "Time",
|
||||
logout: "Log out",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
common: {
|
||||
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
minutesAgo: (n) => `hace ${n}m`,
|
||||
hoursAgo: (n) => `hace ${n}h`,
|
||||
},
|
||||
tab: {
|
||||
headgates: "Compuertas",
|
||||
time: "Tiempo",
|
||||
logout: "Salir",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
waterSmartsheetSyncLog,
|
||||
type SmartsheetColumnMapping,
|
||||
} from "@/db/schema/water-log";
|
||||
import { decryptToken } from "@/lib/crypto";
|
||||
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
||||
import {
|
||||
addRow,
|
||||
findRowByColumn,
|
||||
@@ -159,24 +159,38 @@ export async function syncEntryToSmartsheet(
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Decrypt token + build cells ──
|
||||
let token: string;
|
||||
try {
|
||||
token = decryptToken({
|
||||
cipher: config.encryptedApiToken,
|
||||
iv: config.tokenIv,
|
||||
authTag: config.tokenAuthTag,
|
||||
});
|
||||
} catch (err) {
|
||||
// ── 3. Resolve token from the workspace (Cycle 7) ──
|
||||
const ws = await resolveWorkspaceToken(brandId);
|
||||
if (!ws.ok) {
|
||||
// "no_workspace" + "decryption_failed" are real failures — log
|
||||
// them, bump attempts, and back off. "connection_disabled" is
|
||||
// an intentional admin pause — skip cleanly, don't retry, don't
|
||||
// pollute the activity log with "disabled" failures.
|
||||
if (ws.reason === "connection_disabled") {
|
||||
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(
|
||||
db,
|
||||
brandId,
|
||||
entryId,
|
||||
queue?.attempts ?? 0,
|
||||
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`,
|
||||
reason,
|
||||
start,
|
||||
);
|
||||
}
|
||||
const token = ws.token;
|
||||
|
||||
const cells = buildCells(
|
||||
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",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/time-tracking/smartsheet-sync",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/email-automation/abandoned-cart",
|
||||
"schedule": "0 */6 * * *"
|
||||
|
||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
||||
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
||||
// 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/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/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
||||
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
||||
|
||||
Reference in New Issue
Block a user