Compare commits
64 Commits
658f6a5b8b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19377ea6cf | |||
| c71354d2ae | |||
| da09cfbd1a | |||
| 68851cfede | |||
| b90f0fae52 | |||
| 5a72705e71 | |||
| 129c9d253c | |||
| f8434685a8 | |||
| 60783131ae | |||
| 772e23ded8 | |||
| 5dc9a4604f | |||
| 5188960bd2 | |||
| 6f1aa37a26 | |||
| c7c83890e4 | |||
| e98bbc220f | |||
| 3db642e76b | |||
| 9d0e325c26 | |||
| 34d88d594f | |||
| 06aac296f1 | |||
| 9e6accb3df | |||
| eb0da05037 | |||
| 16ad0955f2 | |||
| ce652ff5de | |||
| da488b2cb0 | |||
| ad4d3c9976 | |||
| 089d77aa68 | |||
| b793cd6955 | |||
| dfd6b63888 | |||
| 0599bdc331 | |||
| 93bcbc29a0 | |||
| c9b6729482 | |||
| bf68fa8431 | |||
| b07be10cf9 | |||
| c160a1d81b | |||
| 81f3f2cc83 | |||
| 5aab432e5a | |||
| 928779e06d | |||
| b061cdd289 | |||
| 71e1792ee3 | |||
| a470b6fe9d | |||
| 61d5e3c6fa | |||
| 7d3de121c6 | |||
| 9910e588b1 | |||
| 35313b16d7 | |||
| 242c195265 | |||
| 12557f947f | |||
| 178b85a9da | |||
| fc70344dab | |||
| 3b0d0c07e3 | |||
| 6de112467a | |||
| 137b5e7ffe | |||
| 060520b7be | |||
| d34fc2434a | |||
| 9ecb496a7c | |||
| 7795263944 | |||
| b29caa0f30 | |||
| c3da54af50 | |||
| 69d8f829f1 | |||
| ce62b17898 | |||
| ac88584b8b | |||
| ca1cf143d3 | |||
| b966787daa | |||
| ca79896d5f | |||
| 98fc1d3bdd |
@@ -79,3 +79,18 @@ CRON_SECRET=replace-me-with-a-random-string
|
||||
# the server actions — but you can override it with:
|
||||
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
||||
# See docs/water-log.md for the full module guide.
|
||||
|
||||
# ── Smartsheet (Water Log integration) ────────────────────────────────────
|
||||
# AES-256-GCM key used to encrypt each brand's Smartsheet API token at
|
||||
# rest in `water_smartsheet_config.encrypted_api_token`. 32 random bytes,
|
||||
# base64-encoded. REQUIRED in production — refuses to start without it.
|
||||
# Generate with: openssl rand -base64 32
|
||||
SMARTSHEET_TOKEN_ENC_KEY=
|
||||
|
||||
# Bearer secret for /api/water-log/smartsheet-sync cron route. Falls
|
||||
# back to CRON_SECRET (above) if unset. Use a unique value per env to
|
||||
# avoid cross-route authorization bleed.
|
||||
SMARTSHEET_CRON_SECRET=
|
||||
|
||||
# HTTP timeout (ms) for outbound Smartsheet API calls. Default 8000.
|
||||
# SMARTSHEET_SYNC_TIMEOUT_MS=8000
|
||||
|
||||
@@ -68,6 +68,8 @@ jobs:
|
||||
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy
|
||||
@@ -108,6 +110,8 @@ jobs:
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
|
||||
run: |
|
||||
set -e
|
||||
@@ -173,6 +177,8 @@ jobs:
|
||||
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
||||
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
||||
printf 'SMARTSHEET_TOKEN_ENC_KEY=%s\n' "$SMARTSHEET_TOKEN_ENC_KEY"
|
||||
printf 'SMARTSHEET_CRON_SECRET=%s\n' "$SMARTSHEET_CRON_SECRET"
|
||||
} > "$ENV_FILE"
|
||||
|
||||
# Upload env file and sync build output
|
||||
@@ -193,10 +199,45 @@ jobs:
|
||||
echo "Ensuring migration directories on server and copying runner + SQL..."
|
||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/smartsheet-backfill-entry-ids.mjs tyler@route.crispygoat.com:$APP_DIR/scripts/smartsheet-backfill-entry-ids.mjs 2>/dev/null || true
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
|
||||
|
||||
# Install deps and restart on server
|
||||
echo "Installing deps and restarting PM2..."
|
||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||
# IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
|
||||
# run `node .next/standalone/server.js` (not `next start`) — `next start`
|
||||
# against a standalone build is unsupported and silently disables the
|
||||
# image optimizer (every `/_next/image?url=...` returns
|
||||
# `"url" parameter is not allowed`). See commit 129c9d2.
|
||||
#
|
||||
# We also need to (1) ship the standalone loader script
|
||||
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
|
||||
# `.next/standalone/.next/static/` so the standalone server can
|
||||
# serve client-side JS/CSS chunks, (3) copy `public/` into
|
||||
# `.next/standalone/public/` (REQUIRED for Next.js standalone —
|
||||
# without it, `/_next/image?url=/brand-logos/...` returns 400
|
||||
# because the optimizer can't resolve local relative paths),
|
||||
# and (4) load `.env` via a Node loader — bash's
|
||||
# `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||
# `&channel_binding=require`.
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true
|
||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \
|
||||
# Standalone server reads .next/static/ relative to itself.
|
||||
mkdir -p .next/standalone/.next && \
|
||||
rm -rf .next/standalone/.next/static && \
|
||||
cp -r .next/static .next/standalone/.next/static && \
|
||||
# Next.js standalone REQUIRES public/ in the standalone tree;
|
||||
# without it, the image optimizer returns 400 on local-relative
|
||||
# image URLs (e.g. /brand-logos/.../logo.png) and storefront
|
||||
# logos / favicons go blank. Mirror public/ → .next/standalone/public/.
|
||||
rm -rf .next/standalone/public && \
|
||||
mkdir -p .next/standalone/public && \
|
||||
cp -r public/. .next/standalone/public/ && \
|
||||
# Start with the wrapper that loads .env and exec's the server.
|
||||
# `pm2 start` is idempotent via `pm2 restart` after the first run.
|
||||
pm2 delete route-commerce 2>/dev/null || true; \
|
||||
PORT=3100 HOSTNAME=0.0.0.0 pm2 start scripts/start-standalone.cjs --name route-commerce --interpreter /home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node 2>&1 | tail -3 && \
|
||||
pm2 save && sleep 4 && \
|
||||
curl -fsS http://localhost:3100/api/health/db-schema >/dev/null || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||
|
||||
echo "Deployed successfully"
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Smartsheet sync cron
|
||||
|
||||
# Replaces the `vercel.json` cron entry (ignored on self-hosted Gitea).
|
||||
# Drains the Smartsheet sync queue every 15 minutes via the running
|
||||
# Next.js app's POST /api/water-log/smartsheet-sync endpoint.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 15 minutes. Gitea Actions uses standard 5-field cron syntax;
|
||||
# `0,15,30,45 * * * *` is the explicit form to avoid `*/15`
|
||||
# parser quirks across older Gitea versions.
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
drain-sync-queue:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Hit /api/water-log/smartsheet-sync
|
||||
env:
|
||||
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
||||
SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$SMARTSHEET_CRON_SECRET" ]; then
|
||||
echo "SMARTSHEET_CRON_SECRET secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$SITE_URL" ]; then
|
||||
echo "NEXT_PUBLIC_SITE_URL secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "POST $SITE_URL/api/water-log/smartsheet-sync"
|
||||
curl -fsS --max-time 60 -X POST \
|
||||
-H "Authorization: Bearer $SMARTSHEET_CRON_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: gitea-smartsheet-cron/1" \
|
||||
"${SITE_URL%/}/api/water-log/smartsheet-sync" \
|
||||
--data '{}' \
|
||||
-w "\n[http %{http_code} in %{time_total}s]\n"
|
||||
@@ -42,6 +42,12 @@ supabase/.temp/
|
||||
# Playwright test results (generated, not source)
|
||||
test-results/
|
||||
playwright-report/
|
||||
tests/_artifacts/
|
||||
|
||||
# Local debug / smoke scripts (not part of the product)
|
||||
scripts/cycle*-debug.mjs
|
||||
scripts/cycle*-smoke.mjs
|
||||
scripts/cycle*-smoke-session.mjs
|
||||
|
||||
# IDE / local config
|
||||
.mcp.json
|
||||
|
||||
@@ -625,3 +625,141 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
||||
- `origin/main` (GitHub) — for backup visibility
|
||||
- 36 commits ahead of `origin/main` as of this entry
|
||||
|
||||
|
||||
## 2026-07: Smartsheet sync added to Water Log module
|
||||
|
||||
Per-brand Smartsheet integration that pushes new `water_log_entries`
|
||||
rows to a configured sheet. Config UI lives in
|
||||
`/admin/water-log/settings` as a self-contained card.
|
||||
|
||||
**Key files added:**
|
||||
- `db/migrations/0093_water_smartsheet_sync.sql` — 3 tables:
|
||||
`water_smartsheet_config` (one row per brand; encrypted token +
|
||||
sheet ID + column mapping + frequency + enable flag + last-sync
|
||||
metadata), `water_smartsheet_sync_queue` (one row per entry
|
||||
awaiting sync; status / attempts / backoff), `water_smartsheet_sync_log`
|
||||
(append-only Recent Activity feed for the UI). RLS via the existing
|
||||
`current_brand_id()` / `is_platform_admin()` pattern.
|
||||
- `db/schema/water-log.ts` — appended the three tables + types
|
||||
(`SmartsheetFrequency`, `SmartsheetColumnKey`, `SmartsheetColumnMapping`).
|
||||
Uses a small custom `bytea` Drizzle type since `drizzle-orm/pg-core`
|
||||
doesn't export one in this version.
|
||||
- `src/lib/crypto.ts` — AES-256-GCM helpers (`encryptToken`,
|
||||
`decryptToken`, `maskToken`). Reads `SMARTSHEET_TOKEN_ENC_KEY`.
|
||||
Throws on missing/wrong-size key — no insecure default.
|
||||
- `src/lib/smartsheet.ts` — REST API wrapper (no SDK — `smartsheet`
|
||||
npm has Node 22 compat issues). Exposes `extractSheetId`,
|
||||
`getSheetMeta`, `addRow`, `findRowByColumn`, and a typed
|
||||
`SmartsheetApiError` class.
|
||||
- `src/services/smartsheet-sync.ts` — pure sync engine:
|
||||
`syncEntryToSmartsheet`, `drainSyncQueue`, `runScheduledSync`.
|
||||
Sequential per-row processing to stay well under Smartsheet's
|
||||
300 req/min cap. Exponential backoff capped at 60 min, max 5
|
||||
attempts before the row stays `failed` permanently.
|
||||
- `src/actions/water-log/smartsheet.ts` — `"use server"` actions:
|
||||
`getSmartsheetConfig`, `saveSmartsheetConfig`, `deleteSmartsheetConfig`,
|
||||
`testSmartsheetConnection`, `triggerSyncForEntry`, `listSmartsheetSyncLog`.
|
||||
Token never returned in plaintext — UI gets `maskedToken` only.
|
||||
- `src/components/admin/water-log/SmartsheetIntegrationCard.tsx` —
|
||||
client component using `useReducer`. Sections: Enable toggle,
|
||||
Sheet URL/ID + API token + Test Connection, sync frequency radio
|
||||
group, column mapping dropdowns (populated after Test), Recent
|
||||
Activity panel.
|
||||
- `src/app/api/water-log/smartsheet-sync/route.ts` — POST cron route.
|
||||
Bearer-auth via `SMARTSHEET_CRON_SECRET` (falls back to `CRON_SECRET`).
|
||||
Optional `{brandId}` body to drain one brand.
|
||||
- `vercel.json` — added `*/15 * * * *` cron entry pointing at the
|
||||
route above. Single entry covers both 15-min and hourly frequencies
|
||||
(route filters by per-brand `sync_frequency`).
|
||||
|
||||
**Hooks into existing code:**
|
||||
- `src/actions/water-log/field.ts::submitWaterEntry` — calls
|
||||
`triggerSyncForEntry(brandId, entryId)` in a fire-and-forget void
|
||||
after the entry insert. Sync failures NEVER bubble to the field
|
||||
worker.
|
||||
- `src/app/admin/water-log/settings/page.tsx` — mounted the new card
|
||||
as a new "§ 05 — Integrations" section after the existing admin-
|
||||
PIN settings. Card takes `brandId` as a prop (CLAUDE.md "Brand ID
|
||||
Threading").
|
||||
|
||||
**Env vars (`.env.example`):**
|
||||
- `SMARTSHEET_TOKEN_ENC_KEY` — REQUIRED, 32 random bytes base64.
|
||||
Generate: `openssl rand -base64 32`.
|
||||
- `SMARTSHEET_CRON_SECRET` — optional bearer for the cron route.
|
||||
- `SMARTSHEET_SYNC_TIMEOUT_MS` — optional, default 8000.
|
||||
|
||||
**Migration push (Gitea deploy + manual):**
|
||||
```bash
|
||||
npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql
|
||||
```
|
||||
Then provision the encryption key in **Gitea repository secrets**
|
||||
(`Settings → Secrets and variables → Actions → Secrets`):
|
||||
`SMARTSHEET_TOKEN_ENC_KEY` (paste the `openssl rand -base64 32` output).
|
||||
Optionally also set `SMARTSHEET_CRON_SECRET`.
|
||||
|
||||
**Rollback story:**
|
||||
1. Remove the cron entry from `vercel.json`.
|
||||
2. SQL rollback: `DROP TABLE water_smartsheet_sync_log;`
|
||||
`DROP TABLE water_smartsheet_sync_queue;` `DROP TABLE water_smartsheet_config;`
|
||||
3. Revert the changes to `field.ts` and `settings/page.tsx`.
|
||||
4. `git revert` the merge commit.
|
||||
|
||||
**Known sharp edges:**
|
||||
- No key rotation support — changing `SMARTSHEET_TOKEN_ENC_KEY` makes
|
||||
existing tokens unreadable; admins must re-enter. Document if/when
|
||||
Phase 2 adds a `key_version` column.
|
||||
- Share-URL Smartsheet sheets often have opaque slugs (not numeric);
|
||||
if "Test Connection" 404s with a slug URL, ask the brand admin for
|
||||
the numeric sheet ID from `File → Properties` in Smartsheet.
|
||||
- `findRowByColumn` is O(n) over up to 2,500 rows; fine for typical
|
||||
water-log sheets but switches to search-API dependency if any
|
||||
brand ever exceeds ~10k entries/year.
|
||||
|
||||
## 2026-07: Gitea API runs on internal port 3013, not the public 3000
|
||||
|
||||
When using the Gitea REST API from a script or cron job that runs
|
||||
*on the Gitea host itself*, hit `http://localhost:3013`, NOT
|
||||
`http://localhost:3000`. The public port (3000) is the reverse proxy
|
||||
and does NOT route `/api/v1/*` directly. The internal Gitea port is
|
||||
set by `LOCAL_ROOT_URL` in `/home/git/custom/conf/app.ini`.
|
||||
|
||||
This came up while adding the SMARTSHEET_TOKEN_ENC_KEY secret:
|
||||
1. Initial PUT to `localhost:3000/api/v1/...` returned HTML 404
|
||||
(the 3000 service doesn't speak the Gitea API)
|
||||
2. Switched to `localhost:3013` and got HTTP 201
|
||||
|
||||
For API tokens used in cron / automation, prefer generating them
|
||||
via `gitea admin user generate-access-token` (CLI) over the UI.
|
||||
The token shows once in stdout; capture immediately and unset.
|
||||
There's no `delete-access-token` subcommand in this Gitea version
|
||||
(`admin user delete-access-token` errors with "flag not defined") —
|
||||
to revoke, DELETE directly from the SQLite `access_token` table:
|
||||
|
||||
```bash
|
||||
sudo -S -p "" -u git python3 -c "
|
||||
import sqlite3
|
||||
c = sqlite3.connect('/home/git/data/gitea.db')
|
||||
print(c.execute(\"DELETE FROM access_token WHERE name='MY-TOKEN'\").rowcount)
|
||||
c.commit()
|
||||
" <<< "$SUDOPW"
|
||||
```
|
||||
|
||||
Gitea is on **SQLite** here (`/home/git/data/gitea.db`), not the
|
||||
`HOST = 127.0.0.1:3306` listed in app.ini (that's stale config from
|
||||
when the DB was MySQL).
|
||||
|
||||
## 2026-07: Smartsheet cron now runs via Gitea Actions, not vercel.json
|
||||
|
||||
The `*/15` cron entry in `vercel.json` is silently ignored because
|
||||
the host is self-hosted Gitea, not Vercel. Replaced with
|
||||
`.gitea/workflows/smartsheet-cron.yml`, schedule `0,15,30,45 * * * *`.
|
||||
|
||||
`deploy.yml` now writes `SMARTSHEET_TOKEN_ENC_KEY` and
|
||||
`SMARTSHEET_CRON_SECRET` into the runtime `.env`. Without this,
|
||||
any future deploy would silently drop these secrets and break
|
||||
runtime sync (encrypted token storage, cron auth).
|
||||
|
||||
If the cron ever returns 401, check `SMARTSHEET_CRON_SECRET`
|
||||
matches between Gitea repo secrets and the live `.env`. The
|
||||
runtime route at `POST /api/water-log/smartsheet-sync` falls
|
||||
back to `CRON_SECRET` if `SMARTSHEET_CRON_SECRET` is unset.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
-- ============================================================================
|
||||
-- 0093_water_smartsheet_sync.sql
|
||||
--
|
||||
-- Adds per-brand Smartsheet integration for the Water Log module.
|
||||
--
|
||||
-- Three tables:
|
||||
-- 1. water_smartsheet_config — one row per brand; encrypted token,
|
||||
-- sheet id, column mapping, frequency,
|
||||
-- enable flag, last-sync metadata
|
||||
-- 2. water_smartsheet_sync_queue — one row per water_log_entry that
|
||||
-- needs syncing; tracks attempts + status
|
||||
-- for retry / observability
|
||||
-- 3. water_smartsheet_sync_log — append-only log of every sync attempt
|
||||
-- (success OR failure) — backs the
|
||||
-- "Recent Activity" panel in the UI
|
||||
--
|
||||
-- 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).
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_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 water-log fields → Smartsheet column IDs.
|
||||
-- Shape: { entry_id: string, logged_at: string, headgate: string,
|
||||
-- measurement: string, unit: string, irrigator: string,
|
||||
-- notes: string | null }
|
||||
-- `entry_id` and `logged_at` 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 water_smartsheet_config_set_updated_at ON water_smartsheet_config;
|
||||
CREATE TRIGGER water_smartsheet_config_set_updated_at
|
||||
BEFORE UPDATE ON water_smartsheet_config
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- RLS: brand-scoped.
|
||||
ALTER TABLE water_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_config;
|
||||
CREATE POLICY tenant_isolation ON water_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 water_log_entry awaiting sync) ──────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_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 entry. ON DELETE CASCADE means deleting an entry also
|
||||
-- removes its queue row, preventing orphaned sync attempts.
|
||||
entry_id UUID NOT NULL REFERENCES water_log_entries(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,
|
||||
|
||||
-- One queue row per entry per brand. CASCADE handles the case where
|
||||
-- the entry is deleted (rare; only via admin override).
|
||||
CONSTRAINT water_smartsheet_queue_entry_unique UNIQUE (brand_id, entry_id)
|
||||
);
|
||||
|
||||
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_status_idx
|
||||
ON water_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||
|
||||
-- Recent-attempts view.
|
||||
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_created_idx
|
||||
ON water_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE water_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_queue;
|
||||
CREATE POLICY tenant_isolation ON water_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 water_smartsheet_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
entry_id UUID REFERENCES water_log_entries(id) ON DELETE SET NULL,
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'sync' (initial push) | 'retry' (after a failure) | 'skip'
|
||||
-- (dedup hit — entry already had smartsheet_row_id) | 'test'
|
||||
-- (the "Test Connection" button does NOT log here; that's a config
|
||||
-- event recorded via water_audit_log instead).
|
||||
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 water_smartsheet_log_brand_recent_idx
|
||||
ON water_smartsheet_sync_log (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE water_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_log;
|
||||
CREATE POLICY tenant_isolation ON water_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 entry (for dedup decisions) ────────────
|
||||
|
||||
-- One row per entry with the most recent sync attempt. Used by the
|
||||
-- sync service to decide "has this entry been pushed already?".
|
||||
CREATE OR REPLACE VIEW water_smartsheet_latest_sync AS
|
||||
SELECT DISTINCT ON (q.brand_id, q.entry_id)
|
||||
q.brand_id,
|
||||
q.entry_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 water_smartsheet_sync_queue q
|
||||
ORDER BY q.brand_id, q.entry_id, q.created_at DESC;
|
||||
|
||||
COMMENT ON TABLE water_smartsheet_config IS
|
||||
'Per-brand Smartsheet integration config. Token is AES-256-GCM encrypted.';
|
||||
COMMENT ON TABLE water_smartsheet_sync_queue IS
|
||||
'One row per water_log_entry awaiting / completed sync to Smartsheet.';
|
||||
COMMENT ON TABLE water_smartsheet_sync_log IS
|
||||
'Append-only audit of Smartsheet sync attempts (success and failure).';
|
||||
@@ -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,322 @@
|
||||
-- ============================================================================
|
||||
-- 0097_field_workers.sql
|
||||
--
|
||||
-- Cycle 10 — unify `water_irrigators` and `time_tracking_workers` into a
|
||||
-- single `field_workers` table. One row per person, one `pin_hash`, one
|
||||
-- role vocabulary. Permanent fix for the silent-desync class of bugs
|
||||
-- (a worker changing their water PIN while their time PIN stays the same,
|
||||
-- or vice versa) and unlocks future domains (harvest reports, equipment
|
||||
-- logs) without yet another `*_workers` table.
|
||||
--
|
||||
-- Why one table:
|
||||
-- - Today two tables store the same person twice with separate PIN
|
||||
-- hashes and disjoint role vocabularies (`irrigator`/`water_admin` vs
|
||||
-- `worker`/`time_admin`). The Tuxedo worker PWA at `/water` already
|
||||
-- shows ONE PIN entry screen but under the hood has to look up the
|
||||
-- right row in the right table depending on which tab the worker
|
||||
-- hits (Cycle 4 attempted unification via a best-effort cross-call
|
||||
-- in `MobileWaterApp.handlePinSubmit`).
|
||||
-- - Phase 2 (separate cycle) collapses the two session cookies and
|
||||
-- replaces the dual verify actions with a single `verifyFieldWorkerPin`.
|
||||
-- This cycle ships the schema + action layer; UI behavior is preserved.
|
||||
--
|
||||
-- Schema:
|
||||
-- id UUID PK (new — old worker IDs are not reused)
|
||||
-- brand_id UUID NOT NULL → brands(id) ON DELETE CASCADE
|
||||
-- name TEXT NOT NULL
|
||||
-- pin_hash TEXT NOT NULL — scrypt self-describing format
|
||||
-- from `@/lib/water-log-pin`
|
||||
-- role TEXT NOT NULL DEFAULT 'worker'
|
||||
-- — union of all four old role values
|
||||
-- language_preference TEXT NOT NULL DEFAULT 'en'
|
||||
-- phone TEXT NULL (water-only; null for time-only workers)
|
||||
-- notes TEXT NULL (water-only)
|
||||
-- active BOOLEAN NOT NULL DEFAULT true
|
||||
-- last_used_at TIMESTAMPTZ NULL
|
||||
-- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
--
|
||||
-- Migration behavior (idempotent — safe to re-run):
|
||||
-- 1. Create field_workers + brand-scoped RLS.
|
||||
-- 2. Add nullable `field_worker_id` columns on the four downstream
|
||||
-- tables that currently FK into the old worker tables.
|
||||
-- 3. Backfill: collapse water + time rows that share a (brand_id,
|
||||
-- lower(trim(name))) key. PIN conflicts → water's wins (water is
|
||||
-- the older surface, preserves existing auth). Names unique to
|
||||
-- one side → copy verbatim.
|
||||
-- 4. Verify every downstream FK row got a field_worker_id (must be 0
|
||||
-- NULL before we make the column NOT NULL).
|
||||
-- 5. Drop the old worker tables; CASCADE drops the OLD FK columns
|
||||
-- (irrigator_id, worker_id) entirely. We do not preserve them —
|
||||
-- the new `field_worker_id` columns are populated and become the
|
||||
-- canonical reference.
|
||||
-- 6. Reissue the partial unique index
|
||||
-- `time_tracking_logs_one_open_per_worker_idx` against
|
||||
-- `field_worker_id` (DB-level invariant: at most one open
|
||||
-- clock-in per worker; preserved).
|
||||
--
|
||||
-- DESTRUCTIVE: drops `water_irrigators` and `time_tracking_workers`
|
||||
-- entirely. The pin hashes are preserved verbatim into field_workers,
|
||||
-- so no worker has to re-learn their PIN after deploy. Old IDs are not
|
||||
-- preserved — every downstream FK is re-pointed to the new IDs. This
|
||||
-- matters for any external system that referenced the old UUIDs; none
|
||||
-- of our surfaces do.
|
||||
--
|
||||
-- Customer impact (Tuxedo has 10 water + 10 time workers today, no name
|
||||
-- overlap):
|
||||
-- - After deploy: 20 `field_workers` rows, every entry/log row
|
||||
-- re-pointed at the right new UUID. Workers do NOT need to re-enter
|
||||
-- a PIN. The water PIN entry screen continues to work; the time
|
||||
-- PIN entry screen continues to work. The cross-call best-effort
|
||||
-- in `MobileWaterApp` continues to work.
|
||||
-- - The role select in `/admin/water-log/users/[id]` will be tightened
|
||||
-- to drop `time_admin` (water-only workers shouldn't get time powers).
|
||||
-- The role select in `/admin/time-tracking` will be tightened to drop
|
||||
-- the silently-rejected `supervisor`/`admin` values that the explore
|
||||
-- audit caught.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Create field_workers ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_workers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
pin_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin')),
|
||||
language_preference TEXT NOT NULL DEFAULT 'en',
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS field_workers_brand_idx
|
||||
ON field_workers(brand_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS field_workers_brand_active_idx
|
||||
ON field_workers(brand_id, active)
|
||||
WHERE active = true;
|
||||
|
||||
COMMENT ON TABLE field_workers IS
|
||||
'Cycle 10 — unified worker table replacing water_irrigators + time_tracking_workers. '
|
||||
'One row per person, one pin_hash. Role vocabulary: worker, time_admin, irrigator, water_admin. '
|
||||
'See db/migrations/0097_field_workers.sql for the unification rationale.';
|
||||
|
||||
DROP TRIGGER IF EXISTS field_workers_set_updated_at ON field_workers;
|
||||
CREATE TRIGGER field_workers_set_updated_at
|
||||
BEFORE UPDATE ON field_workers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- Brand-scoped RLS (mirrors 0096's smartsheet_workspace pattern).
|
||||
ALTER TABLE field_workers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON field_workers;
|
||||
CREATE POLICY tenant_isolation ON field_workers FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 2. Add nullable field_worker_id columns on the four downstream tables ─
|
||||
|
||||
ALTER TABLE water_log_entries
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE water_sessions
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE time_tracking_logs
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE time_tracking_notification_log
|
||||
ADD COLUMN IF NOT EXISTS field_worker_id UUID
|
||||
REFERENCES field_workers(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── 3. Backfill field_workers from the two old tables ─────────────────────
|
||||
--
|
||||
-- Insertion order is intentional: WATER first so its rows win on the
|
||||
-- (brand_id, lower(trim(name))) key (we use ON CONFLICT DO NOTHING).
|
||||
-- Time-tracking rows are inserted second; if the (brand_id, name) key
|
||||
-- already has a water row, the time row is dropped (water's PIN wins).
|
||||
|
||||
INSERT INTO field_workers (
|
||||
id, brand_id, name, pin_hash, role, language_preference,
|
||||
phone, notes, active, last_used_at, created_at
|
||||
)
|
||||
SELECT
|
||||
wi.id,
|
||||
wi.brand_id,
|
||||
wi.name,
|
||||
wi.pin_hash,
|
||||
wi.role,
|
||||
wi.language_preference,
|
||||
wi.phone,
|
||||
wi.notes,
|
||||
wi.active,
|
||||
wi.last_used_at,
|
||||
wi.created_at
|
||||
FROM water_irrigators wi
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM field_workers fw
|
||||
WHERE fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
);
|
||||
|
||||
INSERT INTO field_workers (
|
||||
id, brand_id, name, pin_hash, role, language_preference,
|
||||
phone, notes, active, last_used_at, created_at
|
||||
)
|
||||
SELECT
|
||||
tw.id,
|
||||
tw.brand_id,
|
||||
tw.name,
|
||||
tw.pin AS pin_hash,
|
||||
tw.role,
|
||||
tw.lang AS language_preference,
|
||||
NULL AS phone,
|
||||
NULL AS notes,
|
||||
tw.active,
|
||||
tw.last_used_at,
|
||||
tw.created_at
|
||||
FROM time_tracking_workers tw
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM field_workers fw
|
||||
WHERE fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
);
|
||||
|
||||
-- If a (brand, name) pair was in BOTH tables, the time-tracking row was
|
||||
-- skipped above. Re-insert only the conflicting rows using water's pin_hash
|
||||
-- is unnecessary (we already have water's row); this block is a no-op
|
||||
-- safety check.
|
||||
|
||||
-- ── 4. Backfill field_worker_id on downstream tables ──────────────────────
|
||||
--
|
||||
-- Join key: (brand_id, lower(trim(name))). The water side uses
|
||||
-- water_log_entries.irrigator_id → water_irrigators; the time side uses
|
||||
-- time_tracking_logs.worker_id → time_tracking_workers.
|
||||
|
||||
UPDATE water_log_entries wle
|
||||
SET field_worker_id = fw.id
|
||||
FROM water_irrigators wi, field_workers fw
|
||||
WHERE wle.irrigator_id = wi.id
|
||||
AND fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
AND wle.field_worker_id IS NULL;
|
||||
|
||||
UPDATE water_sessions ws
|
||||
SET field_worker_id = fw.id
|
||||
FROM water_irrigators wi, field_workers fw
|
||||
WHERE ws.irrigator_id = wi.id
|
||||
AND fw.brand_id = wi.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(wi.name))
|
||||
AND ws.field_worker_id IS NULL;
|
||||
|
||||
UPDATE time_tracking_logs ttl
|
||||
SET field_worker_id = fw.id
|
||||
FROM time_tracking_workers tw, field_workers fw
|
||||
WHERE ttl.worker_id = tw.id
|
||||
AND fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
AND ttl.field_worker_id IS NULL;
|
||||
|
||||
UPDATE time_tracking_notification_log tnl
|
||||
SET field_worker_id = fw.id
|
||||
FROM time_tracking_workers tw, field_workers fw
|
||||
WHERE tnl.worker_id = tw.id
|
||||
AND fw.brand_id = tw.brand_id
|
||||
AND lower(trim(fw.name)) = lower(trim(tw.name))
|
||||
AND tnl.field_worker_id IS NULL;
|
||||
|
||||
-- ── 5. Verification: every downstream row must have a field_worker_id ────
|
||||
--
|
||||
-- If any of these SELECTs returns a non-zero count, the migration will
|
||||
-- leave NULL FKs. The CHECK ensures the migration FAILS LOUDLY in that
|
||||
-- case rather than silently dropping data on the cascade in step 7.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
water_entries_null INTEGER;
|
||||
water_sessions_null INTEGER;
|
||||
time_logs_null INTEGER;
|
||||
time_notif_null INTEGER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO water_entries_null FROM water_log_entries WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO water_sessions_null FROM water_sessions WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO time_logs_null FROM time_tracking_logs WHERE field_worker_id IS NULL;
|
||||
SELECT count(*) INTO time_notif_null FROM time_tracking_notification_log WHERE field_worker_id IS NULL;
|
||||
|
||||
IF water_entries_null > 0 THEN
|
||||
RAISE EXCEPTION 'water_log_entries has % rows with NULL field_worker_id after backfill', water_entries_null;
|
||||
END IF;
|
||||
IF water_sessions_null > 0 THEN
|
||||
RAISE EXCEPTION 'water_sessions has % rows with NULL field_worker_id after backfill', water_sessions_null;
|
||||
END IF;
|
||||
IF time_logs_null > 0 THEN
|
||||
RAISE EXCEPTION 'time_tracking_logs has % rows with NULL field_worker_id after backfill', time_logs_null;
|
||||
END IF;
|
||||
IF time_notif_null > 0 THEN
|
||||
RAISE EXCEPTION 'time_tracking_notification_log has % rows with NULL field_worker_id after backfill', time_notif_null;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 6. Make field_worker_id NOT NULL (we've verified backfill is complete)
|
||||
|
||||
ALTER TABLE water_log_entries
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE water_sessions
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE time_tracking_logs
|
||||
ALTER COLUMN field_worker_id SET NOT NULL;
|
||||
|
||||
-- time_tracking_notification_log.worker_id was originally nullable
|
||||
-- (ON DELETE SET NULL on the old FK); preserve that contract.
|
||||
|
||||
-- ── 7. Drop the old worker tables ────────────────────────────────────────
|
||||
--
|
||||
-- CASCADE removes FK constraints and dependent indexes/triggers, but does
|
||||
-- NOT drop the foreign-key columns from unrelated tables. Drop those
|
||||
-- explicitly so downstream tables carry only the new `field_worker_id`.
|
||||
|
||||
DROP TABLE IF EXISTS water_irrigators CASCADE;
|
||||
DROP TABLE IF EXISTS time_tracking_workers CASCADE;
|
||||
|
||||
ALTER TABLE water_log_entries DROP COLUMN IF EXISTS irrigator_id;
|
||||
ALTER TABLE water_sessions DROP COLUMN IF EXISTS irrigator_id;
|
||||
ALTER TABLE time_tracking_logs DROP COLUMN IF EXISTS worker_id;
|
||||
-- time_tracking_notification_log.worker_id was originally nullable
|
||||
-- (ON DELETE SET NULL); we keep the column shape and just drop the old FK
|
||||
-- constraint. The new `field_worker_id` column was added above and
|
||||
-- backfilled. We drop the old column only if the new one is populated.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM time_tracking_notification_log WHERE field_worker_id IS NULL
|
||||
) THEN
|
||||
ALTER TABLE time_tracking_notification_log DROP COLUMN worker_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 8. Reissue the partial unique index on the new column ─────────────────
|
||||
--
|
||||
-- DB-level invariant preserved: at most one open clock-in per worker.
|
||||
|
||||
DROP INDEX IF EXISTS time_tracking_logs_one_open_per_worker_idx;
|
||||
|
||||
CREATE UNIQUE INDEX time_tracking_logs_one_open_per_worker_idx
|
||||
ON time_tracking_logs (field_worker_id)
|
||||
WHERE clock_out IS NULL;
|
||||
|
||||
-- ── 9. Summary of row counts after migration (for the audit log) ─────────
|
||||
--
|
||||
-- Run this manually after the migration completes if you want to confirm:
|
||||
-- SELECT (SELECT count(*) FROM field_workers) AS field_workers,
|
||||
-- (SELECT count(*) FROM water_log_entries WHERE field_worker_id IS NULL) AS wle_null,
|
||||
-- (SELECT count(*) FROM time_tracking_logs WHERE field_worker_id IS NULL) AS ttl_null;
|
||||
@@ -0,0 +1,443 @@
|
||||
-- ============================================================================
|
||||
-- 0098_time_tracking_timesheets.sql
|
||||
--
|
||||
-- Time Tracking — Phase 2 (Timesheets, GPS, Approval/Lock, Audit)
|
||||
--
|
||||
-- This migration extends the existing time-tracking schema with the
|
||||
-- production / payroll features the field app + admin UI need:
|
||||
--
|
||||
-- 1. New roles on `field_workers` — `driver`, `supervisor`
|
||||
-- (kept distinct from `worker` so admin UIs can filter / grant
|
||||
-- approval rights without expanding the existing role semantics).
|
||||
--
|
||||
-- 2. GPS verification on every clock-in / clock-out —
|
||||
-- `time_tracking_logs` gains lat/lng/accuracy columns for both
|
||||
-- events, plus a boolean `gps_verified` (false when the device
|
||||
-- denied geolocation, returned an error, or low-accuracy fix).
|
||||
--
|
||||
-- 3. Manual time entries —
|
||||
-- `time_tracking_logs.entry_kind` becomes `'clock' | 'manual'`.
|
||||
-- Manual rows carry `manual_reason` (the why-this-was-added text
|
||||
-- required by payroll / labor law) and can be edited freely until
|
||||
-- the parent timesheet is locked.
|
||||
--
|
||||
-- 4. Edit audit fields on `time_tracking_logs` —
|
||||
-- `edited_at`, `edited_by` (admin_user id), `edit_reason`. Filled
|
||||
-- by every edit that lands on a row; never cleared.
|
||||
--
|
||||
-- 5. NEW: `time_tracking_timesheets` — one row per (worker, pay
|
||||
-- period). Statuses drive the approval workflow:
|
||||
-- draft → submitted → approved (locked) → unlocked (admin only,
|
||||
-- audit note required)
|
||||
-- → draft (after rejection)
|
||||
-- When `locked_at IS NOT NULL` the underlying logs become
|
||||
-- read-only to anyone except `platform_admin` / `time_admin`.
|
||||
--
|
||||
-- 6. NEW: `time_tracking_audit_log` — append-only audit trail for
|
||||
-- every mutating action on a timesheet or its entries. Every
|
||||
-- server-action call that mutates a timesheet or a log row MUST
|
||||
-- write a row here in the same transaction.
|
||||
--
|
||||
-- 7. NEW: `time_tracking_supervisor_assignments` — many-to-many
|
||||
-- (supervisor_worker_id, supervisee_worker_id). Supervisors only
|
||||
-- see / approve timesheets for their assigned crew.
|
||||
--
|
||||
-- The migration is idempotent — every ALTER / CREATE uses IF NOT EXISTS
|
||||
-- or a DO block, so re-running on a partially-applied DB is safe.
|
||||
-- ============================================================================
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Role enum extension
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Existing enum (migration 0097) is named `field_workers_role_check`
|
||||
-- via a CHECK constraint, NOT a Postgres ENUM type. Verify by introspecting
|
||||
-- pg_constraint before adding new values.
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_def TEXT;
|
||||
BEGIN
|
||||
-- Fetch the current CHECK expression
|
||||
SELECT pg_get_constraintdef(oid)
|
||||
INTO constraint_def
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'field_workers_role_check';
|
||||
|
||||
IF constraint_def IS NOT NULL THEN
|
||||
-- Drop and recreate with the expanded vocabulary. The set is the union
|
||||
-- of all six role values the codebase now recognises.
|
||||
EXECUTE 'ALTER TABLE field_workers DROP CONSTRAINT field_workers_role_check';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE field_workers
|
||||
ADD CONSTRAINT field_workers_role_check
|
||||
CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin', 'driver', 'supervisor'));
|
||||
|
||||
-- Drop & recreate the Drizzle CHECK by hand if it exists under a different name
|
||||
-- (drizzle-kit may have generated `field_workers_role_check1`).
|
||||
DO $$
|
||||
DECLARE
|
||||
cname TEXT;
|
||||
BEGIN
|
||||
FOR cname IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'field_workers'::regclass
|
||||
AND contype = 'c'
|
||||
AND pg_get_constraintdef(oid) LIKE '%role%'
|
||||
AND conname <> 'field_workers_role_check'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE field_workers DROP CONSTRAINT %I', cname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2. GPS columns + entry_kind + manual_reason + edit audit on logs
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE time_tracking_logs
|
||||
ADD COLUMN IF NOT EXISTS clock_in_lat DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS clock_in_lng DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS clock_in_accuracy_m DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS clock_out_lat DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS clock_out_lng DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS clock_out_accuracy_m DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS gps_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS entry_kind TEXT NOT NULL DEFAULT 'clock',
|
||||
ADD COLUMN IF NOT EXISTS manual_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS edited_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS edit_reason TEXT;
|
||||
|
||||
-- entry_kind CHECK (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'time_tracking_logs_entry_kind_check'
|
||||
) THEN
|
||||
ALTER TABLE time_tracking_logs
|
||||
ADD CONSTRAINT time_tracking_logs_entry_kind_check
|
||||
CHECK (entry_kind IN ('clock', 'manual'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Index for fast "all manual entries this period" queries used by payroll
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_kind_idx
|
||||
ON time_tracking_logs (brand_id, entry_kind, clock_in);
|
||||
|
||||
-- Index for the approval queue (entries that belong to a timesheet status)
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx
|
||||
ON time_tracking_logs (clock_in);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 3. time_tracking_timesheets (the workflow entity)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_timesheets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
|
||||
-- Workflow state
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Submission
|
||||
submitted_at TIMESTAMPTZ,
|
||||
submitted_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
submitted_notes TEXT,
|
||||
|
||||
-- Approval (locks the timesheet)
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
reviewer_comment TEXT,
|
||||
|
||||
-- Rejection (sends back to draft)
|
||||
rejected_at TIMESTAMPTZ,
|
||||
rejected_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
rejection_reason TEXT,
|
||||
|
||||
-- Lock state (mirrors reviewed_at for fast filtering)
|
||||
locked_at TIMESTAMPTZ,
|
||||
locked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Unlock state (rare, admin-only, MUST carry a note)
|
||||
unlocked_at TIMESTAMPTZ,
|
||||
unlocked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
unlock_audit_note TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Pre-computed totals — recomputed by triggers / RPCs, but cached here
|
||||
-- so the timesheet list view doesn't re-aggregate on every render.
|
||||
total_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
regular_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overtime_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
break_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
entry_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Daily-OT flash cache: JSONB { 'YYYY-MM-DD': minutes }
|
||||
daily_totals JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT time_tracking_timesheets_period_check
|
||||
CHECK (period_end >= period_start),
|
||||
CONSTRAINT time_tracking_timesheets_status_check
|
||||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_timesheets_worker_period_uniq
|
||||
ON time_tracking_timesheets (field_worker_id, period_start, period_end);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_status_idx
|
||||
ON time_tracking_timesheets (brand_id, status, period_start DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_worker_idx
|
||||
ON time_tracking_timesheets (brand_id, field_worker_id);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 4. time_tracking_audit_log (append-only)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
actor_id UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
actor_kind TEXT NOT NULL DEFAULT 'admin',
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_brand_recent_idx
|
||||
ON time_tracking_audit_log (brand_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_audit_log_entity_idx
|
||||
ON time_tracking_audit_log (entity_type, entity_id);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Supervisor ↔ supervisee assignments
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_supervisor_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
supervisor_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||
supervisee_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT time_tracking_supervisor_assignments_no_self
|
||||
CHECK (supervisor_field_worker_id <> supervisee_field_worker_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_uniq
|
||||
ON time_tracking_supervisor_assignments (
|
||||
supervisor_field_worker_id, supervisee_field_worker_id
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_supervisor_idx
|
||||
ON time_tracking_supervisor_assignments (supervisor_field_worker_id);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 6. RLS — minimal, since the platform already relies on app-layer
|
||||
-- brand scoping via SECURITY DEFINER / Drizzle `withBrand` wrappers.
|
||||
-- The new tables are added to the same RLS scaffolding as the existing
|
||||
-- `time_tracking_*` tables when present; otherwise left open at the
|
||||
-- table level (the app enforces isolation).
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rls_enabled BOOLEAN;
|
||||
BEGIN
|
||||
-- If RLS is enabled on time_tracking_logs, mirror it on the new tables.
|
||||
SELECT relrowsecurity INTO rls_enabled
|
||||
FROM pg_class
|
||||
WHERE relname = 'time_tracking_logs';
|
||||
|
||||
IF rls_enabled THEN
|
||||
EXECUTE 'ALTER TABLE time_tracking_timesheets ENABLE ROW LEVEL SECURITY';
|
||||
EXECUTE 'ALTER TABLE time_tracking_audit_log ENABLE ROW LEVEL SECURITY';
|
||||
EXECUTE 'ALTER TABLE time_tracking_supervisor_assignments ENABLE ROW LEVEL SECURITY';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 7. Triggers — keep `updated_at` fresh
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION time_tracking_set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS time_tracking_timesheets_set_updated_at
|
||||
ON time_tracking_timesheets;
|
||||
CREATE TRIGGER time_tracking_timesheets_set_updated_at
|
||||
BEFORE UPDATE ON time_tracking_timesheets
|
||||
FOR EACH ROW EXECUTE FUNCTION time_tracking_set_updated_at();
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 8. Helper view — admin "what's pending" dashboard
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE VIEW time_tracking_approval_queue AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.brand_id,
|
||||
t.field_worker_id,
|
||||
fw.name AS worker_name,
|
||||
t.period_start,
|
||||
t.period_end,
|
||||
t.status,
|
||||
t.total_minutes,
|
||||
t.regular_minutes,
|
||||
t.overtime_minutes,
|
||||
t.entry_count,
|
||||
t.submitted_at,
|
||||
t.submitted_notes,
|
||||
t.reviewed_at,
|
||||
t.locked_at
|
||||
FROM time_tracking_timesheets t
|
||||
JOIN field_workers fw ON fw.id = t.field_worker_id;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 9. SECURITY DEFINER RPC — recompute timesheet totals
|
||||
-- Called by the server after any entry add/edit/delete so the cached
|
||||
-- totals on `time_tracking_timesheets` stay in sync with the underlying
|
||||
-- `time_tracking_logs`.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION recompute_timesheet_totals(p_timesheet_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
v_brand_id UUID;
|
||||
v_worker_id UUID;
|
||||
v_period_start DATE;
|
||||
v_period_end DATE;
|
||||
v_total INTEGER;
|
||||
v_regular INTEGER;
|
||||
v_ot INTEGER;
|
||||
v_break INTEGER;
|
||||
v_count INTEGER;
|
||||
v_daily JSONB;
|
||||
v_daily_thresh NUMERIC;
|
||||
v_settings_brand UUID;
|
||||
BEGIN
|
||||
-- Load the timesheet header
|
||||
SELECT brand_id, field_worker_id, period_start, period_end
|
||||
INTO v_brand_id, v_worker_id, v_period_start, v_period_end
|
||||
FROM time_tracking_timesheets
|
||||
WHERE id = p_timesheet_id;
|
||||
|
||||
IF v_brand_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Pull daily OT threshold from settings (default 8h)
|
||||
SELECT COALESCE(daily_overtime_threshold, 8)
|
||||
INTO v_daily_thresh
|
||||
FROM time_tracking_settings
|
||||
WHERE brand_id = v_brand_id;
|
||||
v_daily_thresh := COALESCE(v_daily_thresh, 8);
|
||||
|
||||
-- Aggregate underlying logs
|
||||
SELECT
|
||||
COALESCE(SUM(
|
||||
GREATEST(0,
|
||||
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
|
||||
- COALESCE(lunch_break_minutes, 0)
|
||||
)
|
||||
), 0)::INTEGER,
|
||||
COALESCE(SUM(lunch_break_minutes), 0)::INTEGER,
|
||||
COUNT(*)::INTEGER,
|
||||
jsonb_object_agg(
|
||||
d::text, mins
|
||||
) FILTER (WHERE d IS NOT NULL)
|
||||
INTO v_total, v_break, v_count, v_daily
|
||||
FROM (
|
||||
SELECT
|
||||
(clock_in AT TIME ZONE 'America/Denver')::date AS d,
|
||||
SUM(
|
||||
GREATEST(0,
|
||||
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
|
||||
- COALESCE(lunch_break_minutes, 0)
|
||||
)
|
||||
) AS mins
|
||||
FROM time_tracking_logs
|
||||
WHERE brand_id = v_brand_id
|
||||
AND field_worker_id = v_worker_id
|
||||
AND clock_in >= v_period_start
|
||||
AND clock_in < (v_period_end + INTERVAL '1 day')
|
||||
GROUP BY 1
|
||||
) daily_agg
|
||||
RIGHT JOIN time_tracking_logs l
|
||||
ON l.brand_id = v_brand_id
|
||||
AND l.field_worker_id = v_worker_id
|
||||
AND l.clock_in >= v_period_start
|
||||
AND l.clock_in < (v_period_end + INTERVAL '1 day');
|
||||
|
||||
v_daily := COALESCE(v_daily, '{}'::jsonb);
|
||||
|
||||
-- Daily OT = sum across days of (daily_mins - threshold*60)+
|
||||
v_ot := 0;
|
||||
IF jsonb_typeof(v_daily) = 'object' THEN
|
||||
SELECT COALESCE(SUM(GREATEST(0, (v.value::numeric - v_daily_thresh * 60))), 0)::INTEGER
|
||||
INTO v_ot
|
||||
FROM jsonb_each(v_daily) v;
|
||||
END IF;
|
||||
|
||||
v_regular := GREATEST(0, v_total - v_ot);
|
||||
|
||||
UPDATE time_tracking_timesheets
|
||||
SET total_minutes = v_total,
|
||||
regular_minutes = v_regular,
|
||||
overtime_minutes = v_ot,
|
||||
break_minutes = v_break,
|
||||
entry_count = v_count,
|
||||
daily_totals = v_daily
|
||||
WHERE id = p_timesheet_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 10. SECURITY DEFINER RPC — get-or-create a timesheet for a worker × period
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_or_create_timesheet(
|
||||
p_brand_id UUID,
|
||||
p_worker_id UUID,
|
||||
p_period_start DATE,
|
||||
p_period_end DATE
|
||||
)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
SELECT id INTO v_id
|
||||
FROM time_tracking_timesheets
|
||||
WHERE field_worker_id = p_worker_id
|
||||
AND period_start = p_period_start
|
||||
AND period_end = p_period_end;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
INSERT INTO time_tracking_timesheets
|
||||
(brand_id, field_worker_id, period_start, period_end)
|
||||
VALUES
|
||||
(p_brand_id, p_worker_id, p_period_start, p_period_end)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
PERFORM recompute_timesheet_totals(v_id);
|
||||
END IF;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 0099_brand_hero_video.sql
|
||||
-- Add an optional hero_video_url column to brand_settings so each brand can
|
||||
-- point its storefront hero at any video URL (CDN, signed S3, Supabase
|
||||
-- storage, etc.) without a code change. When unset or unplayable, the
|
||||
-- storefront hero falls back to hero_image_url.
|
||||
--
|
||||
-- We intentionally avoid touching the `upsert_brand_settings(...)`
|
||||
-- SECURITY DEFINER RPC here — its 32-parameter positional signature is
|
||||
-- already deployed in production. New value writes use a separate
|
||||
-- idempotent statement.
|
||||
|
||||
ALTER TABLE brand_settings
|
||||
ADD COLUMN IF NOT EXISTS hero_video_url TEXT;
|
||||
|
||||
COMMENT ON COLUMN brand_settings.hero_video_url IS
|
||||
'Optional. Direct URL to a hero background video (mp4/webm). When null or 4xx/5xx, storefront falls back to hero_image_url as a still poster.';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Cycle 10 — Unified field worker table.
|
||||
*
|
||||
* Replaces `water_irrigators` + `time_tracking_workers` (one row per
|
||||
* person with two independent PIN hashes) with one row, one PIN hash,
|
||||
* one role vocabulary.
|
||||
*
|
||||
* Role vocabulary (matches the CHECK constraint in
|
||||
* `db/migrations/0097_field_workers.sql`):
|
||||
* - `worker` — default; can submit water entries + clock in/out
|
||||
* - `time_admin` — can manage time-tracking tasks, settings, and
|
||||
* other time workers
|
||||
* - `irrigator` — legacy role from `water_irrigators`; same powers
|
||||
* as `worker` but kept distinct for UI filtering
|
||||
* (the water admin UI shows this label)
|
||||
* - `water_admin` — can manage headgates, water workers, and water
|
||||
* entries
|
||||
*
|
||||
* The PIN is a scrypt self-describing hash from `@/lib/water-log-pin`
|
||||
* (column name `pin_hash`, NOT `pin` — see cycle 10 migration for the
|
||||
* rename).
|
||||
*/
|
||||
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
export const fieldWorkers = pgTable(
|
||||
"field_workers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
pinHash: text("pin_hash").notNull(),
|
||||
role: text("role", {
|
||||
enum: ["worker", "time_admin", "irrigator", "water_admin", "driver", "supervisor"],
|
||||
})
|
||||
.notNull()
|
||||
.default("worker"),
|
||||
languagePreference: text("language_preference").notNull().default("en"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("field_workers_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type FieldWorker = typeof fieldWorkers.$inferSelect;
|
||||
export type NewFieldWorker = typeof fieldWorkers.$inferInsert;
|
||||
export type FieldWorkerRole = FieldWorker["role"];
|
||||
@@ -14,6 +14,7 @@ export * from "./water-log";
|
||||
export * from "./communications";
|
||||
export * from "./marketing";
|
||||
export * from "./time-tracking";
|
||||
export * from "./field-workers";
|
||||
export * from "./shipping";
|
||||
export * from "./support";
|
||||
export * from "./files";
|
||||
@@ -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;
|
||||
+382
-31
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* 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`).
|
||||
*
|
||||
* Cycle 10: the `time_tracking_workers` table was DROPPED in
|
||||
* `db/migrations/0097_field_workers.sql`. Worker records now live
|
||||
* in `field_workers` (see `db/schema/field-workers.ts`). The
|
||||
* `worker_id` column on the downstream tables
|
||||
* (`time_tracking_logs`, `time_tracking_notification_log`) was
|
||||
* renamed to `field_worker_id` and the FK repointed.
|
||||
*
|
||||
* Cycle 12 (Phase 2): GPS columns + manual entries + audit log +
|
||||
* timesheet approval workflow (0098_time_tracking_timesheets.sql).
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -10,8 +25,12 @@ import {
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
jsonb,
|
||||
date,
|
||||
doublePrecision,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { brands, adminUsers } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
export const timeTrackingSettings = pgTable(
|
||||
"time_tracking_settings",
|
||||
@@ -49,29 +68,12 @@ export const timeTrackingSettings = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingWorkers = pgTable(
|
||||
"time_tracking_workers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
role: text("role", { enum: ["worker", "time_admin"] })
|
||||
.notNull()
|
||||
.default("worker"),
|
||||
lang: text("lang").notNull().default("en"),
|
||||
pin: text("pin").notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
// Cycle 10: `time_tracking_workers` table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in `field_workers`
|
||||
// (see `db/schema/field-workers.ts`). Any code that previously read
|
||||
// from timeTrackingWorkers should now read from fieldWorkers and
|
||||
// filter by role = 'worker' or 'time_admin' if it needs time-only
|
||||
// workers.
|
||||
|
||||
export const timeTrackingTasks = pgTable(
|
||||
"time_tracking_tasks",
|
||||
@@ -103,9 +105,10 @@ export const timeTrackingLogs = pgTable(
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
workerId: uuid("worker_id")
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
@@ -117,14 +120,47 @@ export const timeTrackingLogs = pgTable(
|
||||
submittedVia: text("submitted_via", {
|
||||
enum: ["manual", "field", "import"],
|
||||
}).notNull().default("manual"),
|
||||
|
||||
// ── Migration 0098: GPS + manual entries + edit audit ────────
|
||||
/** `'clock'` = from the field app, `'manual'` = admin-entered. */
|
||||
entryKind: text("entry_kind", { enum: ["clock", "manual"] })
|
||||
.notNull()
|
||||
.default("clock"),
|
||||
/** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */
|
||||
manualReason: text("manual_reason"),
|
||||
/** GPS columns — captured at clock-in / clock-out. Nullable: device
|
||||
* may have denied geolocation, in which case `gpsVerified = false`. */
|
||||
clockInLat: doublePrecision("clock_in_lat"),
|
||||
clockInLng: doublePrecision("clock_in_lng"),
|
||||
clockInAccuracyM: doublePrecision("clock_in_accuracy_m"),
|
||||
clockOutLat: doublePrecision("clock_out_lat"),
|
||||
clockOutLng: doublePrecision("clock_out_lng"),
|
||||
clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"),
|
||||
/** True iff the device supplied a fix with accuracy ≤ 100m. */
|
||||
gpsVerified: boolean("gps_verified").notNull().default(false),
|
||||
/** Edit audit — filled every time a server action updates the row
|
||||
* after creation. Never cleared. */
|
||||
editedAt: timestamp("edited_at", { withTimezone: true }),
|
||||
editedBy: uuid("edited_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
editReason: text("edit_reason"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
|
||||
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
|
||||
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
),
|
||||
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
||||
brandKindIdx: index("time_tracking_logs_brand_kind_idx").on(
|
||||
t.brandId,
|
||||
t.entryKind,
|
||||
t.clockIn,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -135,8 +171,9 @@ export const timeTrackingNotificationLog = pgTable(
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
workerId: uuid("worker_id").references(
|
||||
() => timeTrackingWorkers.id,
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id").references(
|
||||
() => fieldWorkers.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
notificationType: text("notification_type").notNull(),
|
||||
@@ -153,9 +190,323 @@ export const timeTrackingNotificationLog = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
// ── Migration 0098: Timesheet + approval workflow ─────────────────────────
|
||||
|
||||
export const TIMESHEET_STATUSES = [
|
||||
"draft",
|
||||
"submitted",
|
||||
"approved",
|
||||
"rejected",
|
||||
] as const;
|
||||
export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number];
|
||||
|
||||
export const timeTrackingTimesheets = pgTable(
|
||||
"time_tracking_timesheets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
periodStart: date("period_start").notNull(),
|
||||
periodEnd: date("period_end").notNull(),
|
||||
|
||||
status: text("status", { enum: TIMESHEET_STATUSES })
|
||||
.notNull()
|
||||
.default("draft"),
|
||||
|
||||
// Submission
|
||||
submittedAt: timestamp("submitted_at", { withTimezone: true }),
|
||||
submittedBy: uuid("submitted_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
submittedNotes: text("submitted_notes"),
|
||||
|
||||
// Approval (locks the timesheet)
|
||||
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||
reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
reviewerComment: text("reviewer_comment"),
|
||||
|
||||
// Rejection
|
||||
rejectedAt: timestamp("rejected_at", { withTimezone: true }),
|
||||
rejectedBy: uuid("rejected_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
|
||||
// Lock mirror (always equals reviewed_at when status=approved)
|
||||
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||
lockedBy: uuid("locked_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Unlock (rare, admin-only, MUST carry an audit note)
|
||||
unlockedAt: timestamp("unlocked_at", { withTimezone: true }),
|
||||
unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
unlockAuditNote: text("unlock_audit_note").notNull().default(""),
|
||||
|
||||
// Pre-computed totals (recomputed by recompute_timesheet_totals RPC)
|
||||
totalMinutes: integer("total_minutes").notNull().default(0),
|
||||
regularMinutes: integer("regular_minutes").notNull().default(0),
|
||||
overtimeMinutes: integer("overtime_minutes").notNull().default(0),
|
||||
breakMinutes: integer("break_minutes").notNull().default(0),
|
||||
entryCount: integer("entry_count").notNull().default(0),
|
||||
/** Map of date string ("YYYY-MM-DD") → minutes worked that day. */
|
||||
dailyTotals: jsonb("daily_totals").notNull().default({}),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
workerPeriodUniq: index(
|
||||
"time_tracking_timesheets_worker_period_uniq",
|
||||
).on(t.fieldWorkerId, t.periodStart, t.periodEnd),
|
||||
brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on(
|
||||
t.brandId,
|
||||
t.status,
|
||||
t.periodStart,
|
||||
),
|
||||
brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on(
|
||||
t.brandId,
|
||||
t.fieldWorkerId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Append-only audit log — every mutating action on a timesheet or its
|
||||
* underlying logs MUST write a row here in the same DB transaction. */
|
||||
export const timeTrackingAuditLog = pgTable(
|
||||
"time_tracking_audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
/** Admin user (or null when the actor was the worker themselves). */
|
||||
actorId: uuid("actor_id").references(() => adminUsers.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
actorLabel: text("actor_label").notNull(),
|
||||
actorKind: text("actor_kind").notNull().default("admin"),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: uuid("entity_id"),
|
||||
details: jsonb("details").notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
entityIdx: index("time_tracking_audit_log_entity_idx").on(
|
||||
t.entityType,
|
||||
t.entityId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Supervisor ↔ supervisee edges. The brand admin assigns edges; the
|
||||
* approval queue filters by them. */
|
||||
export const timeTrackingSupervisorAssignments = pgTable(
|
||||
"time_tracking_supervisor_assignments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
supervisorFieldWorkerId: uuid("supervisor_field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
superviseeFieldWorkerId: uuid("supervisee_field_worker_id")
|
||||
.notNull()
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniq: index("time_tracking_supervisor_assignments_uniq").on(
|
||||
t.supervisorFieldWorkerId,
|
||||
t.superviseeFieldWorkerId,
|
||||
),
|
||||
supervisorIdx: index(
|
||||
"time_tracking_supervisor_assignments_supervisor_idx",
|
||||
).on(t.supervisorFieldWorkerId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
||||
export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
|
||||
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
|
||||
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
|
||||
// if you need time-only workers.
|
||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect;
|
||||
export type TimeTrackingTimesheetInsert =
|
||||
typeof timeTrackingTimesheets.$inferInsert;
|
||||
export type TimeTrackingAuditLogEntry =
|
||||
typeof timeTrackingAuditLog.$inferSelect;
|
||||
export type TimeTrackingSupervisorAssignment =
|
||||
typeof timeTrackingSupervisorAssignments.$inferSelect;
|
||||
|
||||
// ── Inferred types ──
|
||||
|
||||
// 0098 — daily totals JSON shape stored on the timesheet
|
||||
export type DailyTotals = Record<string, number>;
|
||||
|
||||
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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;
|
||||
+198
-36
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Six tables, all brand-scoped with RLS:
|
||||
* - water_headgates — physical gates a measurement is tied to
|
||||
* - water_irrigators — PIN-authenticated field workers
|
||||
* - field_workers — unified worker table (cycle 10; replaces
|
||||
* water_irrigators + time_tracking_workers)
|
||||
* - water_sessions — short-lived PIN sessions for irrigators
|
||||
* - water_log_entries — the actual reading logs
|
||||
* - water_alert_log — high/low threshold alert history
|
||||
@@ -24,10 +25,15 @@ import {
|
||||
index,
|
||||
uniqueIndex,
|
||||
integer,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
// 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",
|
||||
@@ -62,40 +68,21 @@ export const waterHeadgates = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterIrrigators = pgTable(
|
||||
"water_irrigators",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
pinHash: text("pin_hash").notNull(),
|
||||
languagePreference: text("language_preference")
|
||||
.notNull()
|
||||
.default("en"),
|
||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||
role: text("role").notNull().default("irrigator"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
// Cycle 10: water_irrigators table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in
|
||||
// `field_workers` (see `db/schema/field-workers.ts`).
|
||||
// Any code that previously read from waterIrrigators should now
|
||||
// read from fieldWorkers and filter by role = 'irrigator' or
|
||||
// 'water_admin' if it needs water-only workers.
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
"water_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
@@ -113,9 +100,10 @@ export const waterLogEntries = pgTable(
|
||||
headgateId: uuid("headgate_id")
|
||||
.notNull()
|
||||
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
/** Raw measurement value, in the entry's `unit`. */
|
||||
measurement: numeric("measurement").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
@@ -142,8 +130,8 @@ export const waterLogEntries = pgTable(
|
||||
t.brandId,
|
||||
t.loggedDate,
|
||||
),
|
||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||
t.irrigatorId,
|
||||
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
t.loggedAt,
|
||||
),
|
||||
}),
|
||||
@@ -244,8 +232,9 @@ export const waterAuditLog = pgTable(
|
||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||
|
||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
|
||||
// Use `FieldWorker` from `./field-workers` instead; filter by role =
|
||||
// 'irrigator' or 'water_admin' if you need water-only workers.
|
||||
|
||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||
@@ -255,3 +244,176 @@ export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||
|
||||
// ── Smartsheet integration (added in 0093_water_smartsheet_sync.sql) ────────
|
||||
|
||||
/**
|
||||
* Allowed sync frequencies. Stored as TEXT to match the convention
|
||||
* in CLAUDE.md ("Status enums stored as TEXT — no PostgreSQL ENUM type").
|
||||
*/
|
||||
export const SMARTSHEET_FREQUENCIES = [
|
||||
"realtime",
|
||||
"every_15_minutes",
|
||||
"hourly",
|
||||
] as const;
|
||||
export type SmartsheetFrequency = (typeof SMARTSHEET_FREQUENCIES)[number];
|
||||
|
||||
/**
|
||||
* The Water-log fields a brand can map to their Smartsheet columns.
|
||||
* `entry_id` and `logged_at` are required (used for dedup).
|
||||
*/
|
||||
export type SmartsheetColumnKey =
|
||||
| "entry_id"
|
||||
| "logged_at"
|
||||
| "headgate"
|
||||
| "measurement"
|
||||
| "unit"
|
||||
| "irrigator"
|
||||
| "notes";
|
||||
export const SMARTSHEET_COLUMN_KEYS: readonly SmartsheetColumnKey[] = [
|
||||
"entry_id",
|
||||
"logged_at",
|
||||
"headgate",
|
||||
"measurement",
|
||||
"unit",
|
||||
"irrigator",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Shape stored in `water_smartsheet_config.column_mapping`. The values
|
||||
* are Smartsheet column IDs (numeric strings, e.g. "8309876543210123").
|
||||
*
|
||||
* Only `entry_id` and `logged_at` are required (used for dedup). All
|
||||
* other fields are nullable — `null` means "not mapped, don't write
|
||||
* this column". The UI uses an empty string in the dropdown to mean
|
||||
* the same thing and we coerce on the server.
|
||||
*/
|
||||
export type SmartsheetColumnMapping = {
|
||||
entry_id: string;
|
||||
logged_at: string;
|
||||
headgate: string | null;
|
||||
measurement: string | null;
|
||||
unit: string | null;
|
||||
irrigator: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export const waterSmartsheetConfig = pgTable("water_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<SmartsheetColumnMapping>()
|
||||
.notNull(),
|
||||
syncFrequency: text("sync_frequency")
|
||||
.$type<SmartsheetFrequency>()
|
||||
.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 type WaterSmartsheetConfig = typeof waterSmartsheetConfig.$inferSelect;
|
||||
export type WaterSmartsheetConfigInsert = typeof waterSmartsheetConfig.$inferInsert;
|
||||
|
||||
/**
|
||||
* Sync queue status. Mirrors the SQL CHECK constraint.
|
||||
*/
|
||||
export const SMARTSHEET_QUEUE_STATUSES = [
|
||||
"pending",
|
||||
"syncing",
|
||||
"synced",
|
||||
"failed",
|
||||
] as const;
|
||||
export type SmartsheetQueueStatus = (typeof SMARTSHEET_QUEUE_STATUSES)[number];
|
||||
|
||||
export const waterSmartsheetSyncQueue = pgTable(
|
||||
"water_smartsheet_sync_queue",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
entryId: uuid("entry_id")
|
||||
.notNull()
|
||||
.references(() => waterLogEntries.id, { onDelete: "cascade" }),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
status: text("status")
|
||||
.$type<SmartsheetQueueStatus>()
|
||||
.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 }),
|
||||
},
|
||||
(t) => ({
|
||||
entryUnique: uniqueIndex("water_smartsheet_queue_entry_unique").on(
|
||||
t.brandId,
|
||||
t.entryId,
|
||||
),
|
||||
brandStatusIdx: index("water_smartsheet_queue_brand_status_idx").on(
|
||||
t.brandId,
|
||||
t.status,
|
||||
t.nextAttemptAt,
|
||||
),
|
||||
brandRecentIdx: index("water_smartsheet_queue_brand_created_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type WaterSmartsheetSyncQueue = typeof waterSmartsheetSyncQueue.$inferSelect;
|
||||
export type WaterSmartsheetSyncQueueInsert = typeof waterSmartsheetSyncQueue.$inferInsert;
|
||||
|
||||
export const SMARTSHEET_LOG_ACTIONS = ["sync", "retry", "skip", "queue"] as const;
|
||||
export type SmartsheetLogAction = (typeof SMARTSHEET_LOG_ACTIONS)[number];
|
||||
|
||||
export const waterSmartsheetSyncLog = pgTable(
|
||||
"water_smartsheet_sync_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
entryId: uuid("entry_id").references(() => waterLogEntries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
action: text("action").$type<SmartsheetLogAction>().notNull(),
|
||||
success: boolean("success").notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("water_smartsheet_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type WaterSmartsheetSyncLog = typeof waterSmartsheetSyncLog.$inferSelect;
|
||||
export type WaterSmartsheetSyncLogInsert = typeof waterSmartsheetSyncLog.$inferInsert;
|
||||
|
||||
@@ -307,13 +307,23 @@ ON CONFLICT DO NOTHING;
|
||||
-- ============================================================================
|
||||
-- 11. Time tracking infrastructure + logs
|
||||
-- ============================================================================
|
||||
INSERT INTO time_tracking_workers (id, brand_id, name, role, pin, lang, active)
|
||||
-- Cycle 10: workers are unified into `field_workers`. We seed both the
|
||||
-- former `time_tracking_workers` (role worker|time_admin) and the former
|
||||
-- `water_irrigators` (role irrigator|water_admin) into the same table.
|
||||
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Worker ' || i, CASE (i%2) WHEN 0 THEN 'worker' ELSE 'time_admin' END, lpad((1000+i)::text, 4, '0'), 'en', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, phone, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'irrigator', 'placeholder-pin-hash', 'en', '+15550100000', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO time_tracking_tasks (id, brand_id, name, unit, sort_order, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Task ' || i, CASE (i%3) WHEN 0 THEN 'hours' WHEN 1 THEN 'pieces' ELSE 'units' END, i, true
|
||||
FROM brands b
|
||||
@@ -321,10 +331,10 @@ CROSS JOIN generate_series(1, 8) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO time_tracking_logs (brand_id, worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes)
|
||||
INSERT INTO time_tracking_logs (brand_id, field_worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM time_tracking_workers WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('worker','time_admin') ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
'Task ' || (1 + (i % 8)),
|
||||
(NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval),
|
||||
@@ -346,18 +356,11 @@ CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO water_irrigators (id, brand_id, name, pin_hash, language_preference, role, phone, active)
|
||||
SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'placeholder-pin-hash', 'en', 'irrigator', '+15550100000', true
|
||||
FROM brands b
|
||||
CROSS JOIN generate_series(1, 5) AS i
|
||||
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
|
||||
INSERT INTO water_log_entries (brand_id, headgate_id, field_worker_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
|
||||
SELECT
|
||||
b.id,
|
||||
(SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM water_irrigators WHERE brand_id = b.id ORDER BY random() LIMIT 1),
|
||||
(SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('irrigator','water_admin') ORDER BY random() LIMIT 1),
|
||||
(50 + (i * 7) % 200)::numeric,
|
||||
'GPM',
|
||||
NOW() - ((i % 14) || ' days')::interval,
|
||||
|
||||
@@ -88,12 +88,13 @@ The site-admin helper (`requireWaterAdminPermission`) keeps using
|
||||
`/admin/water-log/*` (which sits behind Neon Auth in the middleware
|
||||
and on the server).
|
||||
|
||||
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) does make a
|
||||
> best-effort `getAdminUser()` call *after* a successful PIN check, but
|
||||
> only to attach a platform-admin `adminUserId` to the new
|
||||
> `water_admin_sessions` row for audit. It is wrapped in try/catch and
|
||||
> falls back to a zero-UUID placeholder. The `wl_admin_session` is
|
||||
> valid either way — that helper call is not an auth gate.
|
||||
> Note: the action `verifyWaterAdminPin` (in `settings.ts`) is now a
|
||||
> fully PIN-only flow — it does NOT call `getAdminUser()` or
|
||||
> `getSession()` at all. The `adminUserId` on the new
|
||||
> `water_admin_sessions` row is always the zero-UUID placeholder. This
|
||||
> is because `/water/admin/login` is the Tuxedo-brand-specific entry
|
||||
> point and must work for users who are not signed into the platform.
|
||||
> Audit attribution is intentionally deferred to a future ticket.
|
||||
|
||||
### 4.2 New file: `src/actions/water-log/auth.ts`
|
||||
|
||||
@@ -174,23 +175,25 @@ moves.
|
||||
keep their existing `getAdminUser()` gate (site-admin path).
|
||||
- `verifyWaterAdminPin` becomes a pure PIN-only flow: validate format,
|
||||
look up `water_admin_settings` for the brand, verify PIN, mint
|
||||
`wl_admin_session` cookie. **No `getSession()` call.** `getAdminUser()`
|
||||
is still called inside the success path to attach an `adminUserId`
|
||||
to the new session row for audit — wrapped in try/catch, falls back
|
||||
to a zero-UUID placeholder if no admin user is signed in. The
|
||||
session itself is valid either way.
|
||||
`wl_admin_session` cookie. **No `getSession()` call. No
|
||||
`getAdminUser()` call.** `adminUserId` is always the zero-UUID
|
||||
placeholder. The page is the Tuxedo-brand-specific entry point and
|
||||
must work without a platform login.
|
||||
|
||||
### 4.4 Why `verifyWaterAdminPin` is a special case
|
||||
|
||||
`verifyWaterAdminPin` is the most-affected function in this PR. Today:
|
||||
|
||||
1. It calls `await getSession()` (Neon Auth) — drop.
|
||||
2. It calls `getAdminUser()` inside the success path to attach the
|
||||
1. It called `await getSession()` (Neon Auth) — dropped.
|
||||
2. It called `getAdminUser()` inside the success path to attach the
|
||||
caller's platform-admin user to the new `wl_admin_session` row for
|
||||
audit. This is correct and stays, but the call is now **wrapped in
|
||||
a try/catch that swallows any error and falls back to a zero-UUID
|
||||
`adminUserId`**. The `wl_admin_session` is still valid in either
|
||||
case — the cookie holds the brand + session id, not the admin user.
|
||||
audit. That call was wrapped in a try/catch that fell back to a
|
||||
zero-UUID `adminUserId`, but the underlying `getSession()` call
|
||||
inside `getAdminUser()` could still corrupt the Next.js cookie
|
||||
store and cause the subsequent `cookies().set("wl_admin_session",
|
||||
...)` to throw — surfacing as a 500 to users without a platform
|
||||
login. The call is now **fully removed**; `adminUserId` is always
|
||||
the zero-UUID placeholder. Audit attribution is a follow-up.
|
||||
|
||||
This is the right shape because the water-admin PIN flow is for ditch
|
||||
riders / brand water admins, who often aren't platform admins. Tying
|
||||
|
||||
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "picsum.photos",
|
||||
},
|
||||
{
|
||||
// Brand media hosted on the crispygoat MinIO bucket
|
||||
// (e.g. s3.crispygoat.com/videos/wp-import/...)
|
||||
protocol: "https",
|
||||
hostname: "s3.crispygoat.com",
|
||||
},
|
||||
],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
|
||||
@@ -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,134 @@
|
||||
// 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.
|
||||
|
||||
// Bumped to v2 after the Apple HIG polish pass (liquid-glass chrome,
|
||||
// iOS SegmentedControl, ThresholdMeter). The activate handler keeps
|
||||
// only this name + DATA_CACHE — anything else starting with `field-`
|
||||
// (including the old v1) is evicted on the next SW activation, which
|
||||
// happens automatically because the bytes of this file changed.
|
||||
const SHELL_CACHE = "field-shell-v2";
|
||||
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,103 @@
|
||||
/**
|
||||
* Cycle 12 deep-flow smoke — walks Hub → Manual Entry → submit,
|
||||
* Hub → History. Captures screenshots along the way and asserts
|
||||
* each screen renders the expected layout.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||
const OUT = "tests/_artifacts/cycle12";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// Use worker_id 5a83c1a3-... (Worker 1) but spoof the brand so we
|
||||
// hit the Tuxedo brandId the running server expects.
|
||||
const COOKIE_VALUE = [
|
||||
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||
"Worker 1",
|
||||
"time_admin",
|
||||
"en",
|
||||
// Brand 64294306 is the one the server logs show loading from —
|
||||
// matches `TUXEDO_BRAND_ID` constant.
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
].join("|");
|
||||
|
||||
async function snap(page, name) {
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
async function expectText(page, regex, label) {
|
||||
const text = await page.locator("body").innerText();
|
||||
if (!regex.test(text)) {
|
||||
console.error(`✗ ${label}: didn't match ${regex}`);
|
||||
console.error("body snapshot:\n" + text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ ${label}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 412, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await ctx.addCookies([
|
||||
{
|
||||
name: "time_tracking_session",
|
||||
value: COOKIE_VALUE,
|
||||
url: BASE,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||
});
|
||||
|
||||
// ── 1) Hub
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, "01_hub");
|
||||
await expectText(page, /Recent Activity/i, "Hub renders 'Recent activity' tile");
|
||||
await expectText(page, /Manual entry/i, "Hub renders 'Manual entry' tile");
|
||||
|
||||
// ── 2) Manual entry
|
||||
await page.locator('button:has-text("Manual entry")').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap(page, "02_manual_entry");
|
||||
await expectText(page, /Manual entry/i, "Manual entry screen title");
|
||||
await expectText(page, /WHEN/i, "Manual entry 'WHEN' section");
|
||||
await expectText(page, /WHY/i, "Manual entry 'WHY' section");
|
||||
|
||||
// Try to submit empty — should show reason error
|
||||
// Reason field is the second text input on the page (first is Task)
|
||||
const reasonInput = page.locator('input[placeholder*="Required"]').first();
|
||||
await reasonInput.fill("Forgot to clock in this morning");
|
||||
await snap(page, "03_manual_filled");
|
||||
await expectText(page, /Submit entry/i, "Submit button visible");
|
||||
|
||||
// ── 3) Recent activity
|
||||
await page.locator('button[aria-label*="Atr" i], button[aria-label*="Back"]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
// We may not always find the back button — fall back to going to hub via reload
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator('button:has-text("Recent activity")').first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
await snap(page, "04_history");
|
||||
await expectText(page, /Recent Activity|Actividad reciente/i, "History title visible");
|
||||
|
||||
await browser.close();
|
||||
console.log("\n✓ Cycle 12 deep smoke passed.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Cycle 12 — submit a manual entry and verify the success screen
|
||||
* renders. Uses yesterday's date so the server's 30-day window
|
||||
* accepts it.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||
const OUT = "tests/_artifacts/cycle12";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const COOKIE_VALUE = [
|
||||
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||
"Worker 1",
|
||||
"time_admin",
|
||||
"en",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
].join("|");
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 412, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await ctx.addCookies([
|
||||
{
|
||||
name: "time_tracking_session",
|
||||
value: COOKIE_VALUE,
|
||||
url: BASE,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Hub → Manual entry
|
||||
await page.locator('button:has-text("Manual entry")').first().click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Fill form. Date defaults to today; use yesterday's date to make
|
||||
// sure we're definitely within the 30-day window and not in the
|
||||
// future. Use a real-looking shift: 07:00 → 15:30 (8h 30m, minus
|
||||
// 30m lunch = 8h).
|
||||
const yesterday = new Date(Date.now() - 86_400_000);
|
||||
const yyyy = yesterday.getFullYear();
|
||||
const mm = String(yesterday.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(yesterday.getDate()).padStart(2, "0");
|
||||
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
await page.locator('input[type="date"]').first().fill(dateStr);
|
||||
await page.locator('input[placeholder*="Harvesting"]').first().fill("Harvesting");
|
||||
await page
|
||||
.locator('input[placeholder*="Required"]')
|
||||
.first()
|
||||
.fill("Forgot to clock in this morning");
|
||||
await page.screenshot({ path: `${OUT}/05_manual_before_submit.png` });
|
||||
|
||||
await page.locator('button:has-text("Submit entry")').click();
|
||||
// Wait for either success or error
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: `${OUT}/06_after_submit.png` });
|
||||
const text = await page.locator("body").innerText();
|
||||
if (/Entry submitted|Entrada enviada/i.test(text)) {
|
||||
console.log("✓ Success screen rendered");
|
||||
} else if (/16 hours|30 days|3 characters|cannot be in the future|Must be after|Reason/i.test(text)) {
|
||||
console.error("✗ Validation error visible:");
|
||||
console.error(text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("? Unknown state after submit:");
|
||||
console.error(text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Take a clean success shot by scrolling to top
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `${OUT}/07_success.png` });
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* diag-water-pin.mjs — diagnostic script for the "PIN only works
|
||||
* once" bug in the water log module.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Snapshots every row in `water_irrigators` and `water_sessions`.
|
||||
* 2. For each irrigator, verifies the stored `pin_hash` format is
|
||||
* a parseable `scrypt$N$r$p$salt$hash` string.
|
||||
* 3. Cross-checks: any session whose `expires_at < now()` would
|
||||
* fail `requireFieldSession` but should NOT affect `verifyWaterPin`
|
||||
* (verifyWaterPin re-issues a session on success).
|
||||
* 4. Optionally tests a provided PIN against a provided pin_hash to
|
||||
* see if the cryptographic verify matches.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL="postgresql://..." node scripts/diag-water-pin.mjs
|
||||
* DATABASE_URL="..." \
|
||||
* node scripts/diag-water-pin.mjs --pin=4739 --id=<irrigator-uuid>
|
||||
*
|
||||
* Run this BEFORE and AFTER a failing second-login attempt to confirm
|
||||
* whether `pin_hash` is being modified mid-session.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { Pool } from "pg";
|
||||
import { scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const args = Object.fromEntries(
|
||||
process.argv.slice(2).map((a) => {
|
||||
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
|
||||
return m ? [m[1], m[2] ?? true] : [a, true];
|
||||
}),
|
||||
);
|
||||
|
||||
const pin = typeof args.pin === "string" ? args.pin : null;
|
||||
const targetId = typeof args.id === "string" ? args.id : null;
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL.includes("sslmode")
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
function verifyPin(rawPin, stored) {
|
||||
if (!stored || !stored.startsWith("scrypt$")) return false;
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6) return false;
|
||||
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
|
||||
const n = parseInt(nStr, 10),
|
||||
r = parseInt(rStr, 10),
|
||||
p = parseInt(pStr, 10);
|
||||
if (![n, r, p].every(Number.isFinite)) return false;
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(saltB64, "base64");
|
||||
expected = Buffer.from(hashB64, "base64");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== 32) return false;
|
||||
let candidate;
|
||||
try {
|
||||
candidate = scryptSync(rawPin.normalize("NFKC"), salt, 32, {
|
||||
N: n,
|
||||
r,
|
||||
p,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
candidate.length === expected.length &&
|
||||
timingSafeEqual(candidate, expected)
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(s, n = 60) {
|
||||
return s && s.length > n ? s.slice(0, n) + "..." : s;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
console.log("=== SNAPSHOT @ " + new Date().toISOString() + " ===\n");
|
||||
|
||||
// Bypass RLS via withPlatformAdmin equivalent (raw app vars).
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
"SELECT set_config('app.current_brand_id', '', true)",
|
||||
);
|
||||
await client.query(
|
||||
"SELECT set_config('app.platform_admin', 'true', true)",
|
||||
);
|
||||
|
||||
const irrigators = await client.query(
|
||||
"SELECT id, brand_id, name, role, active, " +
|
||||
"substr(pin_hash, 1, 40) || '...' AS pin_hash_preview, " +
|
||||
"length(pin_hash) AS pin_hash_len, last_used_at, created_at " +
|
||||
"FROM water_irrigators ORDER BY name",
|
||||
);
|
||||
|
||||
console.log("water_irrigators (" + irrigators.rowCount + " rows):");
|
||||
for (const r of irrigators.rows) {
|
||||
console.log(` • [${r.id}] ${r.name}`);
|
||||
console.log(` brand=${r.brand_id}`);
|
||||
console.log(` role=${r.role} active=${r.active}`);
|
||||
console.log(` pin_hash_preview="${r.pin_hash_preview}" (len=${r.pin_hash_len})`);
|
||||
console.log(` last_used_at=${r.last_used_at}`);
|
||||
|
||||
// Format check
|
||||
const fullRow = await client.query(
|
||||
"SELECT pin_hash FROM water_irrigators WHERE id = $1",
|
||||
[r.id],
|
||||
);
|
||||
const ph = fullRow.rows[0]?.pin_hash;
|
||||
if (!ph || !ph.startsWith("scrypt$") || ph.split("$").length !== 6) {
|
||||
console.log(` ⚠️ pin_hash has UNEXPECTED format`);
|
||||
} else {
|
||||
console.log(` ✓ pin_hash is well-formed scrypt string`);
|
||||
}
|
||||
|
||||
// Targeted PIN verify
|
||||
if (pin && targetId === r.id) {
|
||||
const ok = verifyPin(pin, ph);
|
||||
console.log(` ${ok ? "✓" : "✗"} pin '${pin}' matches pin_hash for ${r.name} → ${ok}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const sessions = await client.query(
|
||||
"SELECT id, irrigator_id, expires_at, created_at FROM water_sessions ORDER BY created_at DESC LIMIT 50",
|
||||
);
|
||||
console.log("\nwater_sessions (" + sessions.rowCount + " recent rows):");
|
||||
for (const s of sessions.rows) {
|
||||
const expired = new Date(s.expires_at).getTime() < Date.now();
|
||||
const irrigator = irrigators.rows.find((i) => i.id === s.irrigator_id);
|
||||
console.log(
|
||||
` • [${s.id}] irrigator=${s.irrigator_id} (${irrigator?.name ?? "?"}) expires_at=${s.expires_at.toISOString()} ${expired ? "(EXPIRED)" : "(active)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Regression test: brand logo (and all /brand-logos/... images) must load
|
||||
// through Next.js Image optimizer on the tuxedo storefront.
|
||||
//
|
||||
// Pre-fix state:
|
||||
// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
|
||||
// → HTTP 400, body: "url parameter is not allowed"
|
||||
//
|
||||
// Post-fix expectation:
|
||||
// → HTTP 200, content-type: image/* and non-empty body
|
||||
//
|
||||
// Root cause identified: Next.js standalone build is missing the
|
||||
// `.next/standalone/public/` folder. The deploy workflow copies
|
||||
// `.next/static` but not `public/`. Without `public/` in standalone, the
|
||||
// image optimizer can't resolve local-relative URLs.
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
|
||||
const LOGO_PROXY =
|
||||
'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
|
||||
|
||||
const failures = [];
|
||||
|
||||
// 1. Hit the image proxy directly
|
||||
const r = await fetch(LOGO_PROXY, {
|
||||
headers: { 'User-Agent': 'regression-brand-logos/1.0' },
|
||||
});
|
||||
if (r.status !== 200) {
|
||||
failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
|
||||
} else {
|
||||
const ct = r.headers.get('content-type') ?? '';
|
||||
if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
|
||||
const buf = new Uint8Array(await r.arrayBuffer());
|
||||
if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
|
||||
console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
|
||||
}
|
||||
|
||||
// 2. Verify via headless browser — visit page, check that the logo <img> actually rendered.
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const failed = [];
|
||||
page.on('response', (resp) => {
|
||||
const u = resp.url();
|
||||
if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
|
||||
});
|
||||
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
// Scroll to bottom so footer logo loads.
|
||||
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
|
||||
await sleep(4000);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||
await sleep(2000);
|
||||
|
||||
const logoInfo = await page.evaluate(() => {
|
||||
const imgs = Array.from(document.querySelectorAll('img'));
|
||||
const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
|
||||
return logos.map((i) => ({
|
||||
alt: i.alt.slice(0, 40),
|
||||
currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
complete: i.complete,
|
||||
visible: i.getBoundingClientRect().height > 0,
|
||||
}));
|
||||
});
|
||||
console.log('\n LOGO IMAGES ON PAGE:');
|
||||
for (const l of logoInfo) console.log(' ', JSON.stringify(l));
|
||||
|
||||
const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
|
||||
if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
|
||||
if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('\n❌ REGRESSION FAILED:');
|
||||
for (const f of failures) console.error(' -', f);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Production startup wrapper for `next start` when `output: "standalone"`
|
||||
* is set in next.config.ts. Loads .env from a known absolute location,
|
||||
* then exec's the standalone server with the loaded env.
|
||||
*
|
||||
* Why this exists:
|
||||
* 1. `next start` against a standalone build is unsupported by Next.js —
|
||||
* it prints "next start does not work with output: standalone
|
||||
* configuration. Use `node .next/standalone/server.js` instead" — and
|
||||
* silently disables the image optimizer (every `/_next/image?url=...`
|
||||
* returned "url parameter is not allowed"), which is why all the
|
||||
* WP-imported brand imagery never rendered. See commit 129c9d2.
|
||||
*
|
||||
* 2. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||
* `&channel_binding=require`, so the server boots with empty DB env
|
||||
* and the public storefront falls back to dark gradient backgrounds.
|
||||
* A small Node loader sidesteps the bash word-splitting.
|
||||
*
|
||||
* 3. The standalone server reads `.next/static/` relative to itself, so
|
||||
* the deploy workflow must `cp -r .next/static .next/standalone/.next/static`
|
||||
* after every sync.
|
||||
*
|
||||
* Usage:
|
||||
* PORT=3100 HOSTNAME=0.0.0.0 \
|
||||
* NODE_BIN=/home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node \
|
||||
* node /home/tyler/route-commerce/scripts/start-standalone.cjs
|
||||
*
|
||||
* pm2 typically overrides interpreter with its own node and sets `cwd`
|
||||
* arbitrarily, so we resolve the project root from the script's own path
|
||||
* instead of `process.cwd()`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const APP_DIR = path.resolve(__dirname, "..");
|
||||
const ENV_PATH = process.env.ENV_FILE || path.join(APP_DIR, ".env");
|
||||
const STANDALONE = path.join(APP_DIR, ".next", "standalone", "server.js");
|
||||
|
||||
if (!fs.existsSync(STANDALONE)) {
|
||||
console.error(`[start-standalone] Missing standalone server at ${STANDALONE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(ENV_PATH)) {
|
||||
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
console.log(`[start-standalone] loaded env from ${ENV_PATH}`);
|
||||
} else {
|
||||
console.warn(`[start-standalone] no .env at ${ENV_PATH}; relying on existing env`);
|
||||
}
|
||||
|
||||
// Hand off to the standalone server. Re-exec keeps the process title at
|
||||
// `next-server` (next-start.sh sets it) and matches `node .next/standalone/server.js`.
|
||||
const { spawn } = require("child_process");
|
||||
const child = spawn(process.execPath, [STANDALONE], { stdio: "inherit", env: process.env });
|
||||
child.on("exit", (code) => process.exit(code ?? 0));
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(sig, () => child.kill(sig));
|
||||
}
|
||||
@@ -216,6 +216,7 @@ export type BrandSettings = {
|
||||
show_text_alerts: boolean | null;
|
||||
schedule_pdf_notes: string | null;
|
||||
hero_image_url: string | null;
|
||||
hero_video_url: string | null;
|
||||
// Color customization
|
||||
brand_primary_color: string | null;
|
||||
brand_secondary_color: string | null;
|
||||
@@ -261,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
|
||||
|
||||
await getSession(); try {
|
||||
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
|
||||
`SELECT bs.*, b.name AS brand_name, ws.online_payment_enabled AS wholesale_enabled
|
||||
FROM brands b
|
||||
JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
@@ -271,6 +272,7 @@ await getSession(); try {
|
||||
);
|
||||
const data = rows[0];
|
||||
if (!data) {
|
||||
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
|
||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
}
|
||||
return {
|
||||
@@ -278,7 +280,8 @@ await getSession(); try {
|
||||
settings: data,
|
||||
wholesaleEnabled: data.wholesale_enabled,
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error(`[getBrandSettingsPublic] DB error for slug=${brandSlug}:`, err);
|
||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
}
|
||||
}
|
||||
@@ -311,6 +314,7 @@ export async function saveBrandSettings(params: {
|
||||
showTextAlerts?: boolean;
|
||||
schedulePdfNotes?: string;
|
||||
heroImageUrl?: string;
|
||||
heroVideoUrl?: string;
|
||||
brandPrimaryColor?: string;
|
||||
brandSecondaryColor?: string;
|
||||
brandBgColor?: string;
|
||||
@@ -372,7 +376,26 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
params.nexusStates ?? null,
|
||||
],
|
||||
);
|
||||
return { success: true, settings: rows[0] as BrandSettings };
|
||||
|
||||
// The `upsert_brand_settings` SECURITY DEFINER RPC has a fixed
|
||||
// 32-parameter signature shipped in production. New columns added
|
||||
// after that signature was frozen (e.g. `hero_video_url` from
|
||||
// migration 0099) are written via a separate idempotent UPDATE.
|
||||
// Pass NULL through unchanged — admin form sends `null` when the
|
||||
// field is left empty.
|
||||
await pool.query(
|
||||
`UPDATE brand_settings
|
||||
SET hero_video_url = $2
|
||||
WHERE brand_id = $1`,
|
||||
[params.brandId, params.heroVideoUrl ?? null],
|
||||
);
|
||||
|
||||
// Re-read so callers see the merged row.
|
||||
const merged = await pool.query<BrandSettings>(
|
||||
`SELECT * FROM brand_settings WHERE brand_id = $1 LIMIT 1`,
|
||||
[params.brandId],
|
||||
);
|
||||
return { success: true, settings: merged.rows[0] ?? (rows[0] as BrandSettings) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save";
|
||||
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
|
||||
|
||||
@@ -48,12 +48,30 @@ interface ClientAction {
|
||||
|
||||
async function listClientActions(): Promise<ClientAction[]> {
|
||||
|
||||
await getSession(); // Wire real server actions here as they land:
|
||||
await getSession();
|
||||
// Wire real server actions here as they land:
|
||||
// { name: "markOrderReady", handler: markOrderReady },
|
||||
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||
// { name: "adjustProductStock", handler: adjustProductStock },
|
||||
return [];
|
||||
//
|
||||
// Time Tracking (cycle 12) — replay handlers for offline clock
|
||||
// events. The replay re-verifies the worker's PIN before invoking
|
||||
// the field action, then the action writes with submitted_via
|
||||
// distinguishable so payroll can audit.
|
||||
const { replayOfflineClock } = await import("./time-tracking/offline-handlers");
|
||||
return [
|
||||
{
|
||||
name: "timeTracking.clockIn",
|
||||
handler: async (payload: unknown, _clientActionId: string) =>
|
||||
replayOfflineClock(payload as never),
|
||||
},
|
||||
{
|
||||
name: "timeTracking.clockOut",
|
||||
handler: async (payload: unknown, _clientActionId: string) =>
|
||||
replayOfflineClock(payload as never),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function dispatchClientAction(
|
||||
|
||||
@@ -234,12 +234,15 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
|
||||
// `customers` replaced `communication_contacts`. `source` column is gone,
|
||||
// so `imports` is always 0 and `new_contacts` is the day's net add.
|
||||
// Wrap COUNT in SUM() so the window function operates on an aggregate,
|
||||
// not the raw `c.id` column (which is not in GROUP BY).
|
||||
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
|
||||
`SELECT
|
||||
d::date::text AS date,
|
||||
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
|
||||
0::int AS imports,
|
||||
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
|
||||
SUM(COUNT(c.id) FILTER (WHERE c.created_at::date = d::date))
|
||||
OVER (ORDER BY d::date)::int AS total
|
||||
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||
LEFT JOIN customers c
|
||||
ON c.created_at::date = d::date
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Time Tracking — Manual time entry server actions.
|
||||
*
|
||||
* Cycle 12 (Phase 2): admins can add/edit/delete manual entries on a
|
||||
* timesheet, but only when the timesheet is in `draft` / `rejected` /
|
||||
* `unlocked` status. Locked (`approved`) timesheets must be unlocked
|
||||
* first via `unlockTimesheet()`.
|
||||
*
|
||||
* Manual entries:
|
||||
* - Carry `entry_kind = 'manual'` + a mandatory `manual_reason`
|
||||
* - Bypass the partial-unique open-clock-in constraint
|
||||
* - Recompute the parent timesheet's cached totals on every write
|
||||
*
|
||||
* All actions write to `time_tracking_audit_log` in the same transaction.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTx } from "@/lib/db";
|
||||
import { logTimeTrackingEventInTx } from "@/lib/time-tracking-audit";
|
||||
|
||||
// ── Schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GPS_SCHEMA = z
|
||||
.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
accuracy_m: z.number().min(0).max(10000).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CreateSchema = z.object({
|
||||
brandId: z.string().uuid(),
|
||||
workerId: z.string().uuid(),
|
||||
taskName: z.string().min(1).max(120),
|
||||
clockIn: z.string().datetime(),
|
||||
clockOut: z.string().datetime().optional().nullable(),
|
||||
lunchBreakMinutes: z.number().int().min(0).max(720).default(0),
|
||||
notes: z.string().max(2000).optional(),
|
||||
manualReason: z.string().min(3).max(500),
|
||||
gps: GPS_SCHEMA,
|
||||
});
|
||||
|
||||
export async function addManualTimeEntry(
|
||||
input: z.infer<typeof CreateSchema>,
|
||||
): Promise<{ success: boolean; entry_id?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const parsed = CreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||
};
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
return withTx(async (client) => {
|
||||
// Verify worker belongs to the brand
|
||||
const fw = await client.query<{ brand_id: string }>(
|
||||
`SELECT brand_id FROM field_workers WHERE id = $1 LIMIT 1`,
|
||||
[data.workerId],
|
||||
);
|
||||
if (fw.rows.length === 0 || fw.rows[0].brand_id !== data.brandId) {
|
||||
return { success: false, error: "Worker not found in this brand" };
|
||||
}
|
||||
|
||||
// Compute the pay-period [start, end] that contains clockIn
|
||||
const period = computePeriod(data.clockIn);
|
||||
|
||||
// Lock the timesheet row (FOR UPDATE) so we can safely decide whether
|
||||
// to insert or update the parent timesheet.
|
||||
const tsRes = await client.query<{
|
||||
id: string;
|
||||
locked_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, locked_at FROM time_tracking_timesheets
|
||||
WHERE field_worker_id = $1
|
||||
AND period_start = $2::date
|
||||
AND period_end = $3::date
|
||||
FOR UPDATE`,
|
||||
[data.workerId, period.start, period.end],
|
||||
);
|
||||
let timesheetId = tsRes.rows[0]?.id;
|
||||
if (tsRes.rows[0]?.locked_at) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Timesheet is locked — ask an admin to unlock it first",
|
||||
};
|
||||
}
|
||||
|
||||
if (!timesheetId) {
|
||||
const created = await client.query<{ id: string }>(
|
||||
`INSERT INTO time_tracking_timesheets
|
||||
(brand_id, field_worker_id, period_start, period_end)
|
||||
VALUES ($1, $2, $3::date, $4::date)
|
||||
RETURNING id`,
|
||||
[data.brandId, data.workerId, period.start, period.end],
|
||||
);
|
||||
timesheetId = created.rows[0]?.id;
|
||||
if (!timesheetId) return { success: false, error: "Failed to create timesheet" };
|
||||
}
|
||||
|
||||
const gpsVerified = !!data.gps && (data.gps.accuracy_m ?? 999) <= 100;
|
||||
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO time_tracking_logs
|
||||
(brand_id, field_worker_id, task_name,
|
||||
clock_in, clock_out, lunch_break_minutes, notes,
|
||||
submitted_via, entry_kind, manual_reason,
|
||||
clock_in_lat, clock_in_lng, clock_in_accuracy_m,
|
||||
gps_verified,
|
||||
edited_at, edited_by, edit_reason)
|
||||
VALUES ($1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
'manual', 'manual', $8,
|
||||
$9, $10, $11,
|
||||
$12,
|
||||
NOW(), $13, $14)
|
||||
RETURNING id`,
|
||||
[
|
||||
data.brandId,
|
||||
data.workerId,
|
||||
data.taskName,
|
||||
new Date(data.clockIn),
|
||||
data.clockOut ? new Date(data.clockOut) : null,
|
||||
data.lunchBreakMinutes,
|
||||
data.notes ?? null,
|
||||
data.manualReason,
|
||||
data.gps?.lat ?? null,
|
||||
data.gps?.lng ?? null,
|
||||
data.gps?.accuracy_m ?? null,
|
||||
gpsVerified,
|
||||
adminUser.id,
|
||||
data.manualReason,
|
||||
],
|
||||
);
|
||||
const entryId = inserted.rows[0]?.id;
|
||||
if (!entryId) return { success: false, error: "Insert failed" };
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: data.brandId,
|
||||
actorId: adminUser.id,
|
||||
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
|
||||
action: "entry.create",
|
||||
entityType: "log",
|
||||
entityId: entryId,
|
||||
details: {
|
||||
kind: "manual",
|
||||
worker_id: data.workerId,
|
||||
clock_in: data.clockIn,
|
||||
clock_out: data.clockOut,
|
||||
reason: data.manualReason,
|
||||
},
|
||||
});
|
||||
|
||||
await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]);
|
||||
|
||||
return { success: true, entry_id: entryId };
|
||||
});
|
||||
}
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
entryId: z.string().uuid(),
|
||||
taskName: z.string().min(1).max(120).optional(),
|
||||
clockIn: z.string().datetime().optional(),
|
||||
clockOut: z.string().datetime().nullable().optional(),
|
||||
lunchBreakMinutes: z.number().int().min(0).max(720).optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
manualReason: z.string().min(3).max(500),
|
||||
gps: GPS_SCHEMA,
|
||||
});
|
||||
|
||||
export async function editTimeEntry(
|
||||
input: z.infer<typeof UpdateSchema>,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const parsed = UpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||
};
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
return withTx(async (client) => {
|
||||
const cur = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
locked_at: Date | null;
|
||||
}>(
|
||||
`SELECT t.brand_id, t.field_worker_id, ts.locked_at
|
||||
FROM time_tracking_logs t
|
||||
LEFT JOIN time_tracking_timesheets ts
|
||||
ON ts.field_worker_id = t.field_worker_id
|
||||
AND ts.period_start <= t.clock_in::date
|
||||
AND ts.period_end >= t.clock_in::date
|
||||
WHERE t.id = $1
|
||||
FOR UPDATE OF t`,
|
||||
[data.entryId],
|
||||
);
|
||||
if (cur.rows.length === 0) return { success: false, error: "Not found" };
|
||||
if (cur.rows[0].locked_at)
|
||||
return {
|
||||
success: false,
|
||||
error: "Timesheet is locked — ask an admin to unlock it first",
|
||||
};
|
||||
|
||||
const gpsVerified =
|
||||
data.gps != null ? (data.gps.accuracy_m ?? 999) <= 100 : undefined;
|
||||
|
||||
// Build a dynamic UPDATE so we only touch columns that the caller
|
||||
// actually changed. Cleanly handles the clockOut=null sentinel for
|
||||
// "clear the clock-out".
|
||||
const sets: string[] = [
|
||||
"edited_at = NOW()",
|
||||
"edited_by = $1",
|
||||
"edit_reason = $2",
|
||||
];
|
||||
const params: unknown[] = [adminUser.id, data.manualReason];
|
||||
let i = params.length;
|
||||
|
||||
if (data.taskName !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`task_name = $${i}`);
|
||||
params.push(data.taskName);
|
||||
}
|
||||
if (data.clockIn !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`clock_in = $${i}`);
|
||||
params.push(new Date(data.clockIn));
|
||||
}
|
||||
if (data.clockOut !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`clock_out = $${i}::timestamptz`);
|
||||
params.push(data.clockOut ? new Date(data.clockOut) : null);
|
||||
}
|
||||
if (data.lunchBreakMinutes !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`lunch_break_minutes = $${i}`);
|
||||
params.push(data.lunchBreakMinutes);
|
||||
}
|
||||
if (data.notes !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`notes = $${i}`);
|
||||
params.push(data.notes);
|
||||
}
|
||||
if (data.gps !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`clock_in_lat = $${i}`);
|
||||
params.push(data.gps.lat);
|
||||
i += 1;
|
||||
sets.push(`clock_in_lng = $${i}`);
|
||||
params.push(data.gps.lng);
|
||||
i += 1;
|
||||
sets.push(`clock_in_accuracy_m = $${i}`);
|
||||
params.push(data.gps.accuracy_m ?? null);
|
||||
if (gpsVerified !== undefined) {
|
||||
i += 1;
|
||||
sets.push(`gps_verified = $${i}`);
|
||||
params.push(gpsVerified);
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
const entryIdParam = i;
|
||||
params.push(data.entryId);
|
||||
|
||||
await client.query(
|
||||
`UPDATE time_tracking_logs SET ${sets.join(", ")} WHERE id = $${entryIdParam}`,
|
||||
params,
|
||||
);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: cur.rows[0].brand_id,
|
||||
actorId: adminUser.id,
|
||||
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
|
||||
action: "entry.edit",
|
||||
entityType: "log",
|
||||
entityId: data.entryId,
|
||||
details: {
|
||||
worker_id: cur.rows[0].field_worker_id,
|
||||
reason: data.manualReason,
|
||||
},
|
||||
});
|
||||
|
||||
// Recompute the owning timesheet
|
||||
await client.query(
|
||||
`SELECT recompute_timesheet_totals(ts.id)
|
||||
FROM time_tracking_timesheets ts
|
||||
WHERE ts.field_worker_id = $1
|
||||
LIMIT 1`,
|
||||
[cur.rows[0].field_worker_id],
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTimeEntry(
|
||||
entryId: string,
|
||||
reason: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!reason || reason.length < 3)
|
||||
return { success: false, error: "Reason required" };
|
||||
|
||||
return withTx(async (client) => {
|
||||
const cur = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
locked_at: Date | null;
|
||||
}>(
|
||||
`SELECT t.brand_id, t.field_worker_id, ts.locked_at
|
||||
FROM time_tracking_logs t
|
||||
LEFT JOIN time_tracking_timesheets ts
|
||||
ON ts.field_worker_id = t.field_worker_id
|
||||
AND ts.period_start <= t.clock_in::date
|
||||
AND ts.period_end >= t.clock_in::date
|
||||
WHERE t.id = $1
|
||||
FOR UPDATE OF t`,
|
||||
[entryId],
|
||||
);
|
||||
if (cur.rows.length === 0) return { success: false, error: "Not found" };
|
||||
if (cur.rows[0].locked_at)
|
||||
return {
|
||||
success: false,
|
||||
error: "Timesheet is locked — ask an admin to unlock it first",
|
||||
};
|
||||
|
||||
await client.query(`DELETE FROM time_tracking_logs WHERE id = $1`, [entryId]);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: cur.rows[0].brand_id,
|
||||
actorId: adminUser.id,
|
||||
actorLabel: adminUser.display_name ?? adminUser.email ?? "admin",
|
||||
action: "entry.delete",
|
||||
entityType: "log",
|
||||
entityId: entryId,
|
||||
details: { worker_id: cur.rows[0].field_worker_id, reason },
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`SELECT recompute_timesheet_totals(ts.id)
|
||||
FROM time_tracking_timesheets ts
|
||||
WHERE ts.field_worker_id = $1
|
||||
LIMIT 1`,
|
||||
[cur.rows[0].field_worker_id],
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function computePeriod(isoDate: string): { start: string; end: string } {
|
||||
// Default weekly period (Sunday → Saturday). The brand admin can
|
||||
// override this via time_tracking_settings.pay_period_length_days +
|
||||
// pay_period_start_day; the SQL recompute_timesheet_totals RPC
|
||||
// understands any non-zero length.
|
||||
const d = new Date(isoDate);
|
||||
const start = new Date(d);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
start.setDate(d.getDate() - d.getDay());
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return {
|
||||
start: start.toISOString().slice(0, 10),
|
||||
end: end.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Time Tracking — Payroll export (CSV + PDF).
|
||||
*
|
||||
* Two endpoints, both keyed on a `timesheetId`:
|
||||
* - CSV: stable, machine-friendly. Columns match the format the Tuxedo
|
||||
* payroll service already ingests for the water log so we can re-use
|
||||
* the same import tool on the payroll side.
|
||||
* - PDF: signature-ready. Includes worker, period, totals, every
|
||||
* entry with start/end, manual entries highlighted, supervisor
|
||||
* approval signature line, and an audit footer.
|
||||
*
|
||||
* Both lock behind the same authz: caller must be admin or the timesheet's
|
||||
* worker (or assigned supervisor). Locked timesheets export; the
|
||||
* approval status is what makes them payroll-ready.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import {
|
||||
timeTrackingLogs,
|
||||
timeTrackingTimesheets,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
import Papa from "papaparse";
|
||||
|
||||
// ── CSV ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ExportRow = {
|
||||
date: string;
|
||||
worker_name: string;
|
||||
worker_role: string;
|
||||
task: string;
|
||||
entry_kind: "clock" | "manual";
|
||||
clock_in: string;
|
||||
clock_out: string;
|
||||
break_minutes: number;
|
||||
total_minutes: number;
|
||||
gps_verified: boolean;
|
||||
manual_reason: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type ExportPayload = {
|
||||
brand_name: string;
|
||||
worker_name: string;
|
||||
worker_role: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
status: string;
|
||||
reviewer: string | null;
|
||||
approved_at: string | null;
|
||||
rows: ExportRow[];
|
||||
totals: {
|
||||
entry_count: number;
|
||||
total_minutes: number;
|
||||
regular_minutes: number;
|
||||
overtime_minutes: number;
|
||||
break_minutes: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function exportTimesheetCsv(
|
||||
timesheetId: string,
|
||||
): Promise<{ success: boolean; csv?: string; filename?: string; error?: string }> {
|
||||
const payload = await loadExportPayload(timesheetId);
|
||||
if (!payload) return { success: false, error: "Not authorized or not found" };
|
||||
|
||||
const csv = Papa.unparse(
|
||||
payload.rows.map((r) => ({
|
||||
Date: r.date,
|
||||
Worker: r.worker_name,
|
||||
Role: r.worker_role,
|
||||
Task: r.task,
|
||||
Kind: r.entry_kind,
|
||||
"Clock In": r.clock_in,
|
||||
"Clock Out": r.clock_out,
|
||||
"Break (min)": r.break_minutes,
|
||||
"Total (min)": r.total_minutes,
|
||||
"GPS Verified": r.gps_verified ? "yes" : "no",
|
||||
"Manual Reason": r.manual_reason ?? "",
|
||||
Notes: r.notes ?? "",
|
||||
})),
|
||||
);
|
||||
|
||||
const header =
|
||||
`# ${payload.brand_name}\n` +
|
||||
`# Worker: ${payload.worker_name} (${payload.worker_role})\n` +
|
||||
`# Period: ${payload.period_start} → ${payload.period_end}\n` +
|
||||
`# Status: ${payload.status}\n` +
|
||||
`# Approver: ${payload.reviewer ?? "(none)"}\n` +
|
||||
`# Approved at: ${payload.approved_at ?? "(not approved)"}\n` +
|
||||
`# Totals: ${payload.totals.total_minutes} min total · ` +
|
||||
`${payload.totals.regular_minutes} reg · ` +
|
||||
`${payload.totals.overtime_minutes} OT · ` +
|
||||
`${payload.totals.break_minutes} break\n` +
|
||||
`#\n`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
csv: header + csv + "\n",
|
||||
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.csv`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── PDF ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function exportTimesheetPdf(
|
||||
timesheetId: string,
|
||||
): Promise<{ success: boolean; pdf?: Uint8Array; filename?: string; error?: string }> {
|
||||
const payload = await loadExportPayload(timesheetId);
|
||||
if (!payload) return { success: false, error: "Not authorized or not found" };
|
||||
|
||||
const pdf = await PDFDocument.create();
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const pageSize: [number, number] = [612, 792]; // US Letter
|
||||
const margin = 40;
|
||||
const lineHeight = 14;
|
||||
|
||||
let page = pdf.addPage(pageSize);
|
||||
let cursor = pageSize[1] - margin;
|
||||
|
||||
function ensureRoom(lines = 1) {
|
||||
if (cursor - lines * lineHeight < margin) {
|
||||
page = pdf.addPage(pageSize);
|
||||
cursor = pageSize[1] - margin;
|
||||
}
|
||||
}
|
||||
|
||||
function drawText(text: string, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) {
|
||||
const size = opts.size ?? 10;
|
||||
const f = opts.bold ? fontBold : font;
|
||||
page.drawText(text, {
|
||||
x: margin,
|
||||
y: cursor - size,
|
||||
size,
|
||||
font: f,
|
||||
color: opts.color ? rgb(...opts.color) : rgb(0, 0, 0),
|
||||
});
|
||||
cursor -= size + 4;
|
||||
}
|
||||
|
||||
// Header
|
||||
drawText(payload.brand_name, { bold: true, size: 16 });
|
||||
drawText(`Timesheet — ${payload.worker_name} (${payload.worker_role})`, {
|
||||
size: 12,
|
||||
});
|
||||
drawText(
|
||||
`Period: ${payload.period_start} → ${payload.period_end} · Status: ${payload.status}`,
|
||||
);
|
||||
if (payload.reviewer) {
|
||||
drawText(`Approver: ${payload.reviewer} · Approved: ${payload.approved_at}`);
|
||||
}
|
||||
cursor -= 4;
|
||||
drawText("Totals", { bold: true });
|
||||
drawText(
|
||||
`Entries: ${payload.totals.entry_count} · ` +
|
||||
`Total: ${formatHM(payload.totals.total_minutes)} · ` +
|
||||
`Regular: ${formatHM(payload.totals.regular_minutes)} · ` +
|
||||
`OT: ${formatHM(payload.totals.overtime_minutes)} · ` +
|
||||
`Break: ${formatHM(payload.totals.break_minutes)}`,
|
||||
);
|
||||
cursor -= 8;
|
||||
|
||||
// Entries
|
||||
drawText("Entries", { bold: true });
|
||||
for (const r of payload.rows) {
|
||||
ensureRoom(3);
|
||||
const tag = r.entry_kind === "manual" ? "[manual] " : "";
|
||||
const gps = r.gps_verified ? "📍" : "—";
|
||||
drawText(
|
||||
`${r.date} ${tag}${r.task} ${gps} ${r.clock_in} → ${r.clock_out} ` +
|
||||
`(${formatHM(r.total_minutes)})`,
|
||||
{ size: 9 },
|
||||
);
|
||||
if (r.notes) drawText(` notes: ${r.notes}`, { size: 8, color: [0.4, 0.4, 0.4] });
|
||||
if (r.manual_reason)
|
||||
drawText(` reason: ${r.manual_reason}`, {
|
||||
size: 8,
|
||||
color: [0.4, 0.4, 0.4],
|
||||
});
|
||||
}
|
||||
|
||||
cursor -= 24;
|
||||
ensureRoom(2);
|
||||
drawText("Signatures", { bold: true });
|
||||
drawText("Employee: ____________________________________ Date: ______________");
|
||||
cursor -= 18;
|
||||
drawText("Supervisor: __________________________________ Date: ______________");
|
||||
|
||||
cursor -= 28;
|
||||
ensureRoom(2);
|
||||
drawText(
|
||||
`Generated ${new Date().toISOString()} · audit id: ${timesheetId.slice(0, 8)}`,
|
||||
{ size: 7, color: [0.5, 0.5, 0.5] },
|
||||
);
|
||||
|
||||
const pdfBytes = await pdf.save();
|
||||
return {
|
||||
success: true,
|
||||
pdf: pdfBytes,
|
||||
filename: `timesheet_${payload.worker_name.replace(/\s+/g, "_")}_${payload.period_start}_${payload.period_end}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Loader ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadExportPayload(
|
||||
timesheetId: string,
|
||||
): Promise<ExportPayload | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
return withBrand(adminUser.brand_id ?? "", async (db) => {
|
||||
const [ts] = await db
|
||||
.select({
|
||||
id: timeTrackingTimesheets.id,
|
||||
brand_id: timeTrackingTimesheets.brandId,
|
||||
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
|
||||
period_start: timeTrackingTimesheets.periodStart,
|
||||
period_end: timeTrackingTimesheets.periodEnd,
|
||||
status: timeTrackingTimesheets.status,
|
||||
total_minutes: timeTrackingTimesheets.totalMinutes,
|
||||
regular_minutes: timeTrackingTimesheets.regularMinutes,
|
||||
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
|
||||
break_minutes: timeTrackingTimesheets.breakMinutes,
|
||||
entry_count: timeTrackingTimesheets.entryCount,
|
||||
approved_at: timeTrackingTimesheets.reviewedAt,
|
||||
reviewer_name: fieldWorkers.name,
|
||||
})
|
||||
.from(timeTrackingTimesheets)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
|
||||
)
|
||||
.where(eq(timeTrackingTimesheets.id, timesheetId))
|
||||
.limit(1);
|
||||
|
||||
if (!ts) return null;
|
||||
if (
|
||||
adminUser.role !== "platform_admin" &&
|
||||
adminUser.brand_id !== ts.brand_id
|
||||
)
|
||||
return null;
|
||||
|
||||
const [worker] = await db
|
||||
.select()
|
||||
.from(fieldWorkers)
|
||||
.where(eq(fieldWorkers.id, ts.field_worker_id))
|
||||
.limit(1);
|
||||
if (!worker) return null;
|
||||
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(timeTrackingLogs)
|
||||
.where(eq(timeTrackingLogs.fieldWorkerId, ts.field_worker_id))
|
||||
.orderBy(timeTrackingLogs.clockIn);
|
||||
|
||||
const rows: ExportRow[] = entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.clockIn >= new Date(ts.period_start) &&
|
||||
e.clockIn <= endOfDay(ts.period_end),
|
||||
)
|
||||
.map((e) => {
|
||||
const totalMin =
|
||||
e.clockOut
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(e.clockOut.getTime() - e.clockIn.getTime()) / 60000,
|
||||
) - e.lunchBreakMinutes,
|
||||
)
|
||||
: 0;
|
||||
return {
|
||||
date: e.clockIn.toISOString().slice(0, 10),
|
||||
worker_name: worker.name,
|
||||
worker_role: worker.role,
|
||||
task: e.taskName,
|
||||
entry_kind: e.entryKind as "clock" | "manual",
|
||||
clock_in: e.clockIn.toISOString().slice(11, 19),
|
||||
clock_out: e.clockOut ? e.clockOut.toISOString().slice(11, 19) : "",
|
||||
break_minutes: e.lunchBreakMinutes,
|
||||
total_minutes: totalMin,
|
||||
gps_verified: e.gpsVerified,
|
||||
manual_reason: e.manualReason,
|
||||
notes: e.notes,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
brand_name: adminUser.brand_slug ?? "Brand",
|
||||
worker_name: worker.name,
|
||||
worker_role: worker.role,
|
||||
period_start: ts.period_start,
|
||||
period_end: ts.period_end,
|
||||
status: ts.status,
|
||||
reviewer: ts.reviewer_name,
|
||||
approved_at: ts.approved_at ? ts.approved_at.toISOString() : null,
|
||||
rows,
|
||||
totals: {
|
||||
entry_count: ts.entry_count,
|
||||
total_minutes: ts.total_minutes,
|
||||
regular_minutes: ts.regular_minutes,
|
||||
overtime_minutes: ts.overtime_minutes,
|
||||
break_minutes: ts.break_minutes,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function endOfDay(iso: string): Date {
|
||||
const d = new Date(iso);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatHM(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
@@ -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, sql, gte, lte } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
timeTrackingTasks,
|
||||
timeTrackingLogs,
|
||||
timeTrackingSmartsheetSyncQueue,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
import { verifyPin } from "@/lib/water-log-pin";
|
||||
import { getWorkerPeriodTotals } from "./index";
|
||||
|
||||
export type TimeTrackingSession = {
|
||||
worker_id: string;
|
||||
@@ -26,75 +43,372 @@ 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
|
||||
// 7 days. The cookie is httpOnly + secure (in prod) + sameSite=lax so
|
||||
// XSS / cross-site theft are already mitigated; the remaining risk is a
|
||||
// shared/unlocked device on the field. Workers notice forced re-auth
|
||||
// more than they notice a longer session, so we keep them signed in for
|
||||
// a full week. Re-PIN only when the cookie expires or is logged out.
|
||||
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
|
||||
|
||||
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.
|
||||
// Cycle 10: workers now live in the unified `field_workers` table.
|
||||
// We accept anyone with a clocking-in role: 'worker' (default),
|
||||
// 'time_admin' (manages tasks + workers), 'irrigator' (legacy role
|
||||
// for water workers, can also clock in/out — these are field crew
|
||||
// who do both jobs), and 'driver' (cycle 12 driver/crew role).
|
||||
//
|
||||
// Excluded on purpose:
|
||||
// - 'water_admin' — admin who manages headgates/workers, not a
|
||||
// clocking-in field worker
|
||||
// - 'supervisor' — cycle 12 approver role; doesn't clock in/out,
|
||||
// only reviews and approves timesheets
|
||||
//
|
||||
// This also matters because MobileWaterApp (the /water unified
|
||||
// entrypoint) calls verifyTimeTrackingPin best-effort right after
|
||||
// verifyWaterPin — if the role filter here is too narrow, the
|
||||
// worker enters their PIN once, the Time tab mounts, asks for the
|
||||
// PIN again, and the second attempt fails the same way. Allowing
|
||||
// the broader set keeps the unified PIN flow working for everyone
|
||||
// who's registered as a field crew member.
|
||||
const rows = await withBrand(brandId, async (db) => {
|
||||
return db
|
||||
.select()
|
||||
.from(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
eq(fieldWorkers.active, true),
|
||||
sql`${fieldWorkers.role} IN ('worker', 'time_admin', 'irrigator', 'driver')`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
let match: typeof fieldWorkers.$inferSelect | undefined;
|
||||
for (const r of rows) {
|
||||
if (verifyPin(pin, r.pinHash)) {
|
||||
match = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) return { success: false, error: "Invalid PIN" };
|
||||
|
||||
// Bump lastUsedAt + set cookie.
|
||||
await withBrand(brandId, async (db) => {
|
||||
await db
|
||||
.update(fieldWorkers)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(fieldWorkers.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.languagePreference,
|
||||
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 type GpsSample = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Reported accuracy in meters. ≤ 100m = `gps_verified = true`. */
|
||||
accuracy_m?: number;
|
||||
};
|
||||
|
||||
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",
|
||||
gps?: GpsSample | null,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
log_id?: string;
|
||||
clock_in?: string;
|
||||
gps_verified?: boolean;
|
||||
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.fieldWorkerId, 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();
|
||||
const gpsVerified =
|
||||
gps != null && (gps.accuracy_m ?? 999) <= 100;
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(timeTrackingLogs)
|
||||
.values({
|
||||
brandId,
|
||||
fieldWorkerId: session.worker_id,
|
||||
taskId: resolvedTaskId,
|
||||
taskName: resolvedTaskName,
|
||||
clockIn: now,
|
||||
submittedVia: "field",
|
||||
// 0098 — GPS + entry_kind
|
||||
entryKind: "clock",
|
||||
clockInLat: gps?.lat ?? null,
|
||||
clockInLng: gps?.lng ?? null,
|
||||
clockInAccuracyM: gps?.accuracy_m ?? null,
|
||||
gpsVerified,
|
||||
})
|
||||
.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(),
|
||||
gps_verified: gpsVerified,
|
||||
};
|
||||
} 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,
|
||||
gps?: GpsSample | null,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
log_id?: string;
|
||||
clock_out?: string;
|
||||
total_minutes?: number;
|
||||
gps_verified?: boolean;
|
||||
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.fieldWorkerId, 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();
|
||||
const gpsVerified =
|
||||
gps != null && (gps.accuracy_m ?? 999) <= 100;
|
||||
await db
|
||||
.update(timeTrackingLogs)
|
||||
.set({
|
||||
clockOut: now,
|
||||
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
|
||||
notes: notes?.trim() || null,
|
||||
// 0098 — GPS on clock-out
|
||||
clockOutLat: gps?.lat ?? null,
|
||||
clockOutLng: gps?.lng ?? null,
|
||||
clockOutAccuracyM: gps?.accuracy_m ?? null,
|
||||
// Preserve the existing `gps_verified` if clock-in already verified;
|
||||
// upgrade only when clock-out GPS is fresh AND accurate.
|
||||
gpsVerified:
|
||||
log.gpsVerified || gpsVerified ? true : log.gpsVerified,
|
||||
})
|
||||
.where(eq(timeTrackingLogs.id, log.id));
|
||||
|
||||
// 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)),
|
||||
);
|
||||
|
||||
// Recompute the owning timesheet totals (0098).
|
||||
void recomputeOwningTimesheet(session.brand_id, log.id).catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
log_id: log.id,
|
||||
clock_out: now.toISOString(),
|
||||
total_minutes: totalMinutes,
|
||||
gps_verified: gpsVerified,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Get Open Clock In ──────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Fire-and-forget: recompute the parent timesheet totals for a log.
|
||||
* Errors are swallowed — the cached totals can be off by one entry until
|
||||
* the next mutation; this is preferable to surfacing a 500 on clock-out.
|
||||
*/
|
||||
async function recomputeOwningTimesheet(
|
||||
brandId: string,
|
||||
logId: string,
|
||||
): Promise<void> {
|
||||
const pool = (await import("@/lib/db")).getPool();
|
||||
await pool.query(
|
||||
`SELECT recompute_timesheet_totals(ts.id)
|
||||
FROM time_tracking_timesheets ts
|
||||
JOIN time_tracking_logs l ON l.field_worker_id = ts.field_worker_id
|
||||
WHERE l.id = $1 AND ts.brand_id = $2
|
||||
LIMIT 1`,
|
||||
[logId, brandId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOpenClockIn(
|
||||
_brandId: string
|
||||
): Promise<{
|
||||
// ── Get Open Clock In ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getOpenClockIn(brandId: string): Promise<{
|
||||
success: boolean;
|
||||
open?: boolean;
|
||||
log_id?: string;
|
||||
@@ -103,34 +417,83 @@ 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 };
|
||||
|
||||
// Session is the source of truth for field workers — see
|
||||
// `submitManualTimeLog` for the rationale. The Hub screen relies on
|
||||
// this returning the worker's actual open shift regardless of which
|
||||
// brand constant the page passes in.
|
||||
const resolvedBrand = session.brand_id ?? brandId;
|
||||
if (!resolvedBrand) return { success: false, error: "Missing brand context" };
|
||||
|
||||
return withBrand(resolvedBrand, async (db) => {
|
||||
const [open] = await db
|
||||
.select()
|
||||
.from(timeTrackingLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingLogs.brandId, resolvedBrand),
|
||||
eq(timeTrackingLogs.fieldWorkerId, 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(fieldWorkers)
|
||||
.where(eq(fieldWorkers.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.languagePreference,
|
||||
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 +505,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 +561,305 @@ 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 };
|
||||
|
||||
// Session is the source of truth for field workers — see
|
||||
// `submitManualTimeLog`. Without this, a stale brand constant on
|
||||
// the page (e.g. during a brand migration) makes the Hub's "logged
|
||||
// today" total read 0 because the period-totals query points at a
|
||||
// brand the worker doesn't belong to.
|
||||
const resolvedBrand = session.brand_id ?? brandId;
|
||||
|
||||
// Settings may not exist yet; default to safe thresholds.
|
||||
const { getTimeTrackingSettings } = await import("./index");
|
||||
const settings = await getTimeTrackingSettings(resolvedBrand);
|
||||
|
||||
const totals = await getWorkerPeriodTotals(
|
||||
resolvedBrand,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Manual time entry (Hub → Manual Entry tile) ───────────────────────────
|
||||
//
|
||||
// Cycle 12: PIN now lands on a Hub screen, and one of the tiles opens a
|
||||
// "manual entry" form for missed clock-in/clock-out shifts. The form
|
||||
// inserts directly into `time_tracking_logs` with `entry_kind='manual'`
|
||||
// and a required `manual_reason`. The same row shape is reused by the
|
||||
// admin timesheet UI (see `src/actions/time-tracking/entries.ts`).
|
||||
//
|
||||
// We deliberately keep these safety bounds:
|
||||
// • Clock-in must be within the last 30 days (workers cannot backfill
|
||||
// last month; payroll is already closed).
|
||||
// • Clock-out must be > clock-in.
|
||||
// • Duration cannot exceed 16 hours (a single shift ceiling matching the
|
||||
// admin UI guard).
|
||||
// • Reason must be ≥ 3 chars (so "x" can't sneak through).
|
||||
// • GPS is not surfaced — manual entries never have GPS, by definition.
|
||||
//
|
||||
// The row is inserted via `withBrand` so RLS context is preserved, and
|
||||
// Smartsheet sync is enqueued best-effort so it matches live clock-outs.
|
||||
|
||||
const MAX_MANUAL_LOOKBACK_DAYS = 30;
|
||||
const MAX_MANUAL_HOURS = 16;
|
||||
const MIN_MANUAL_REASON_LENGTH = 3;
|
||||
|
||||
export type SubmitManualTimeLogInput = {
|
||||
brandId?: string;
|
||||
clockInIso: string;
|
||||
clockOutIso: string;
|
||||
lunchBreakMinutes?: number;
|
||||
taskId?: string | null;
|
||||
taskName?: string | null;
|
||||
notes?: string | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type SubmitManualTimeLogResult =
|
||||
| {
|
||||
success: true;
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string;
|
||||
total_minutes: number;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function submitManualTimeLog(
|
||||
input: SubmitManualTimeLogInput,
|
||||
): Promise<SubmitManualTimeLogResult> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
// Brand resolution: caller may pass an explicit brandId (e.g. the admin
|
||||
// manage page passing platform_admin context), but the field worker app
|
||||
// has only one brand — the session's brand_id.
|
||||
const brandId = input.brandId ?? session.brand_id;
|
||||
if (!brandId) {
|
||||
return { success: false, error: "Missing brand context" };
|
||||
}
|
||||
|
||||
const reason = input.reason.trim();
|
||||
if (reason.length < MIN_MANUAL_REASON_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please add a brief reason (at least 3 characters)",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse + sanity-check the timestamps before hitting the DB.
|
||||
const clockIn = new Date(input.clockInIso);
|
||||
const clockOut = new Date(input.clockOutIso);
|
||||
if (Number.isNaN(clockIn.getTime())) {
|
||||
return { success: false, error: "Clock-in time is invalid" };
|
||||
}
|
||||
if (Number.isNaN(clockOut.getTime())) {
|
||||
return { success: false, error: "Clock-out time is invalid" };
|
||||
}
|
||||
if (clockIn.getTime() >= clockOut.getTime()) {
|
||||
return { success: false, error: "Clock-out must be after clock-in" };
|
||||
}
|
||||
const durationMs = clockOut.getTime() - clockIn.getTime();
|
||||
const durationMinutes = Math.round(durationMs / 60000);
|
||||
if (durationMinutes > MAX_MANUAL_HOURS * 60) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Shift cannot exceed ${MAX_MANUAL_HOURS} hours`,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lookbackCutoff = new Date(
|
||||
now.getTime() - MAX_MANUAL_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
if (clockIn.getTime() < lookbackCutoff.getTime()) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Clock-in is more than ${MAX_MANUAL_LOOKBACK_DAYS} days ago`,
|
||||
};
|
||||
}
|
||||
if (clockIn.getTime() > now.getTime()) {
|
||||
return { success: false, error: "Clock-in cannot be in the future" };
|
||||
}
|
||||
|
||||
const lunchMinutes = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.round(input.lunchBreakMinutes ?? 0),
|
||||
Math.max(0, durationMinutes - 1),
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve a friendly task name when the worker picked a task. Tasks are
|
||||
// brand-scoped, so we resolve inside the brand transaction to avoid
|
||||
// querying wrong-brand rows.
|
||||
let resolvedTaskId: string | null = input.taskId ?? null;
|
||||
let resolvedTaskName = (input.taskName ?? "").trim() || "Manual entry";
|
||||
|
||||
const result = await withBrand(brandId, async (db) => {
|
||||
if (resolvedTaskId) {
|
||||
const t = await db
|
||||
.select({ name: timeTrackingTasks.name })
|
||||
.from(timeTrackingTasks)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingTasks.id, resolvedTaskId),
|
||||
eq(timeTrackingTasks.brandId, brandId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (t.length > 0) resolvedTaskName = t[0].name;
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(timeTrackingLogs)
|
||||
.values({
|
||||
brandId,
|
||||
fieldWorkerId: session.worker_id,
|
||||
taskId: resolvedTaskId,
|
||||
taskName: resolvedTaskName,
|
||||
clockIn,
|
||||
clockOut,
|
||||
lunchBreakMinutes: lunchMinutes,
|
||||
notes: input.notes?.trim() || null,
|
||||
// Manual entries: enum='manual', reason is required, no GPS.
|
||||
entryKind: "manual",
|
||||
manualReason: reason,
|
||||
submittedVia: "manual",
|
||||
gpsVerified: false,
|
||||
})
|
||||
.returning({ id: timeTrackingLogs.id });
|
||||
return inserted[0]?.id;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return { success: false, error: "Failed to save entry" };
|
||||
}
|
||||
|
||||
// Best-effort Smartsheet sync enqueue + timesheet recompute. Same
|
||||
// fire-and-forget posture as `clockOutWorker`.
|
||||
void enqueueClockOutForSync(brandId, result).catch(() => {});
|
||||
void recomputeOwningTimesheet(brandId, result).catch(() => {});
|
||||
|
||||
const totalMinutes = Math.max(0, durationMinutes - lunchMinutes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
log_id: result,
|
||||
clock_in: clockIn.toISOString(),
|
||||
clock_out: clockOut.toISOString(),
|
||||
total_minutes: totalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Recent log fetcher (Hub + Recent Activity screen) ──────────────────────
|
||||
|
||||
/** A flattened, presentation-ready log row. Used by both the Hub hero card
|
||||
* ("logged today" preview) and the Recent Activity screen. */
|
||||
export type WorkerLogRow = {
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
task_name: string;
|
||||
entry_kind: "clock" | "manual";
|
||||
lunch_break_minutes: number;
|
||||
total_minutes: number; // 0 if open shift (clock_out is null)
|
||||
notes: string | null;
|
||||
manual_reason: string | null;
|
||||
};
|
||||
|
||||
export type RecentTimeLogsResult =
|
||||
| { success: true; logs: WorkerLogRow[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function getRecentTimeLogs(
|
||||
brandId: string,
|
||||
days = 14,
|
||||
): Promise<RecentTimeLogsResult> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const safeDays = Math.max(1, Math.min(days, 90));
|
||||
const since = new Date(
|
||||
Date.now() - safeDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
// Session is the source of truth for field workers — see
|
||||
// `submitManualTimeLog` and `getOpenClockIn`. Without this, a stale
|
||||
// brand constant on the page (e.g. during a brand migration) makes
|
||||
// Recent Activity read "No shifts yet" because the query points at a
|
||||
// brand the worker doesn't belong to.
|
||||
const resolvedBrand = session.brand_id ?? brandId;
|
||||
|
||||
return withBrand(resolvedBrand, async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: timeTrackingLogs.id,
|
||||
clockIn: timeTrackingLogs.clockIn,
|
||||
clockOut: timeTrackingLogs.clockOut,
|
||||
taskName: timeTrackingLogs.taskName,
|
||||
entryKind: timeTrackingLogs.entryKind,
|
||||
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
|
||||
notes: timeTrackingLogs.notes,
|
||||
manualReason: timeTrackingLogs.manualReason,
|
||||
})
|
||||
.from(timeTrackingLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingLogs.brandId, resolvedBrand),
|
||||
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
|
||||
gte(timeTrackingLogs.clockIn, new Date(since)),
|
||||
// Only show logs that have a clock-out (the Recent Activity
|
||||
// card represents *completed* shifts). Open shifts are shown
|
||||
// on the Hub hero card via `getOpenClockIn`.
|
||||
),
|
||||
)
|
||||
.orderBy(desc(timeTrackingLogs.clockIn))
|
||||
.limit(200);
|
||||
|
||||
const logs: WorkerLogRow[] = rows.map((r) => {
|
||||
const total_minutes =
|
||||
r.clockOut != null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(r.clockOut.getTime() - r.clockIn.getTime()) / 60000,
|
||||
) - r.lunchBreakMinutes,
|
||||
)
|
||||
: 0;
|
||||
return {
|
||||
log_id: r.id,
|
||||
clock_in: r.clockIn.toISOString(),
|
||||
clock_out: r.clockOut ? r.clockOut.toISOString() : null,
|
||||
task_name: r.taskName,
|
||||
entry_kind: r.entryKind,
|
||||
lunch_break_minutes: r.lunchBreakMinutes,
|
||||
total_minutes,
|
||||
notes: r.notes,
|
||||
manual_reason: r.manualReason,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, logs };
|
||||
});
|
||||
}
|
||||
|
||||
+629
-114
@@ -1,23 +1,34 @@
|
||||
/**
|
||||
* 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: cycle 10 unified the worker tables into `field_workers`
|
||||
* where `pin_hash` (TEXT) carries the scrypt hash (self-describing
|
||||
* format from `@/lib/water-log-pin`). Plaintext PINs are returned
|
||||
* ONCE on create/reset and never persisted.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { eq, and, gte, lte, desc, asc, sql } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
timeTrackingTasks,
|
||||
timeTrackingLogs,
|
||||
timeTrackingSettings,
|
||||
timeTrackingNotificationLog,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
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 +36,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 +51,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 +72,473 @@ 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 fieldWorkers.$inferSelect): TimeWorker {
|
||||
return {
|
||||
id: r.id,
|
||||
brand_id: r.brandId,
|
||||
name: r.name,
|
||||
role: r.role,
|
||||
// Cycle 10: column rename `lang` → `languagePreference` in field_workers.
|
||||
lang: r.languagePreference,
|
||||
// Cycle 10: column rename `pin` → `pinHash` in field_workers.
|
||||
pin: r.pinHash,
|
||||
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(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
// Time admin UI only shows time-domain workers.
|
||||
sql`${fieldWorkers.role} IN ('worker', 'time_admin')`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(fieldWorkers.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(fieldWorkers)
|
||||
.values({
|
||||
brandId,
|
||||
name: trimmed,
|
||||
role,
|
||||
languagePreference: lang,
|
||||
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(fieldWorkers)
|
||||
.set({ pinHash })
|
||||
.where(eq(fieldWorkers.id, workerId))
|
||||
.returning({ id: fieldWorkers.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(fieldWorkers)
|
||||
.set({ name: trimmed, role, languagePreference: lang, active })
|
||||
.where(eq(fieldWorkers.id, workerId))
|
||||
.returning({ id: fieldWorkers.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(fieldWorkers)
|
||||
.where(eq(fieldWorkers.id, workerId))
|
||||
.returning({ id: fieldWorkers.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.fieldWorkerId, 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.fieldWorkerId,
|
||||
worker_name: fieldWorkers.name,
|
||||
task_id: timeTrackingLogs.taskId,
|
||||
task_name: timeTrackingLogs.taskName,
|
||||
clock_in: timeTrackingLogs.clockIn,
|
||||
clock_out: timeTrackingLogs.clockOut,
|
||||
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
|
||||
notes: timeTrackingLogs.notes,
|
||||
submitted_via: timeTrackingLogs.submittedVia,
|
||||
created_at: timeTrackingLogs.createdAt,
|
||||
})
|
||||
.from(timeTrackingLogs)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
|
||||
)
|
||||
.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.fieldWorkerId,
|
||||
workerName: fieldWorkers.name,
|
||||
taskId: timeTrackingLogs.taskId,
|
||||
taskName: timeTrackingLogs.taskName,
|
||||
clockIn: timeTrackingLogs.clockIn,
|
||||
clockOut: timeTrackingLogs.clockOut,
|
||||
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
|
||||
})
|
||||
.from(timeTrackingLogs)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
|
||||
)
|
||||
.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 +559,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 +616,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 +668,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.fieldWorkerId,
|
||||
workerName: fieldWorkers.name,
|
||||
notificationType: timeTrackingNotificationLog.notificationType,
|
||||
recipient: timeTrackingNotificationLog.recipient,
|
||||
status: timeTrackingNotificationLog.status,
|
||||
createdAt: timeTrackingNotificationLog.createdAt,
|
||||
})
|
||||
.from(timeTrackingNotificationLog)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingNotificationLog.fieldWorkerId),
|
||||
)
|
||||
.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.fieldWorkerId, 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,
|
||||
fieldWorkerId: 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,104 @@
|
||||
/**
|
||||
* Time Tracking — Offline replay handlers.
|
||||
*
|
||||
* Wired into the offline dispatcher's `listClientActions()` so the field
|
||||
* app's IndexedDB queue can replay clock-in / clock-out events when the
|
||||
* device comes back online.
|
||||
*
|
||||
* Re-auth model:
|
||||
* The field app stores the worker's PIN locally (encrypted at rest by
|
||||
* the browser) for the lifetime of the session cookie. On PIN entry,
|
||||
* the PIN is written to IndexedDB so a subsequent offline clock event
|
||||
* can carry it. The replay server action re-hashes + compares against
|
||||
* `field_workers.pin_hash` to authenticate the replay.
|
||||
*
|
||||
* Audit:
|
||||
* Replays land with `submitted_via = 'offline_sync'`, a distinct
|
||||
* value from `'field'` and `'manual'`, so payroll can spot and verify
|
||||
* any entry that didn't have a live GPS fix.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { verifyPin } from "@/lib/water-log-pin";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
import { clockInWorker, clockOutWorker, type GpsSample } from "./field";
|
||||
|
||||
export type OfflineClockPayload =
|
||||
| {
|
||||
kind: "clock_in";
|
||||
brandId: string;
|
||||
workerId: string;
|
||||
pin: string;
|
||||
taskName: string;
|
||||
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||
clientTs: string;
|
||||
}
|
||||
| {
|
||||
kind: "clock_out";
|
||||
brandId: string;
|
||||
workerId: string;
|
||||
pin: string;
|
||||
lunchMinutes: number;
|
||||
notes?: string;
|
||||
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||
clientTs: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-verify a PIN against the `field_workers` table. Looks up across all
|
||||
* brands (mirrors the field verify path) and returns the matching worker
|
||||
* or null. Includes the weak 200ms response-time penalty to discourage
|
||||
* brute-force.
|
||||
*/
|
||||
async function reAuthWorker(
|
||||
workerId: string,
|
||||
pin: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!pin || pin.length < 4) return { ok: false, error: "PIN required" };
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db.select().from(fieldWorkers).limit(500);
|
||||
const target = rows.find((r) => r.id === workerId);
|
||||
if (!target || !target.active) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { ok: false as const, error: "Worker not found or inactive" };
|
||||
}
|
||||
if (!verifyPin(pin, target.pinHash)) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { ok: false as const, error: "Invalid PIN" };
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
}
|
||||
|
||||
export async function replayOfflineClock(
|
||||
payload: OfflineClockPayload,
|
||||
): Promise<{ success: boolean; log_id?: string; error?: string }> {
|
||||
const auth = await reAuthWorker(payload.workerId, payload.pin);
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
|
||||
if (payload.kind === "clock_in") {
|
||||
const r = await clockInWorker(
|
||||
payload.brandId,
|
||||
undefined,
|
||||
payload.taskName,
|
||||
payload.gps as GpsSample | null,
|
||||
);
|
||||
return {
|
||||
success: r.success,
|
||||
log_id: r.log_id,
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
|
||||
const r = await clockOutWorker(
|
||||
payload.lunchMinutes,
|
||||
payload.notes,
|
||||
payload.gps as GpsSample | null,
|
||||
);
|
||||
return {
|
||||
success: r.success,
|
||||
log_id: r.log_id,
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Cross-link helper: "Route summary" = a worker-day bundle that shows
|
||||
* BOTH water-log activity AND time-tracking totals side by side.
|
||||
*
|
||||
* The Water Log module works with field workers logging entries at
|
||||
* headgates; the Time Tracking module has them clocking in/out. A
|
||||
* "route" in the water-log domain is really a worker's day, and this
|
||||
* action gives the UI a single object that summarises both halves of
|
||||
* the day.
|
||||
*
|
||||
* Used by:
|
||||
* - /admin/water-log users detail page (driver view)
|
||||
* - /admin/time-tracking overview page (admin dashboard row)
|
||||
*
|
||||
* No mutation, no auth checks beyond getAdminUser(). Returns a typed
|
||||
* shape so client components don't have to know about either module's
|
||||
* table layout.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { and, eq, gte, lt, sql } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPool } from "@/lib/db";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
timeTrackingLogs,
|
||||
type TimesheetStatus,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
import { waterLogEntries } from "@/db/schema/water-log";
|
||||
import { getTimeTrackingSettings } from "@/actions/time-tracking";
|
||||
|
||||
export type RouteTimeSummary = {
|
||||
date: string; // YYYY-MM-DD
|
||||
workerId: string;
|
||||
workerName: string;
|
||||
|
||||
water: {
|
||||
entryCount: number;
|
||||
totalGallons: number;
|
||||
headgatesVisited: string[];
|
||||
};
|
||||
|
||||
time: {
|
||||
clockedIn: boolean;
|
||||
currentLogId: string | null;
|
||||
clockInAt: string | null;
|
||||
lastClockOutAt: string | null;
|
||||
workedMinutesToday: number;
|
||||
breakMinutesToday: number;
|
||||
overtimeMinutesToday: number;
|
||||
regularMinutesToday: number;
|
||||
timesheetStatus: TimesheetStatus | null;
|
||||
timesheetId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkerRouteRow = {
|
||||
workerId: string;
|
||||
workerName: string;
|
||||
active: boolean;
|
||||
water: { entryCount: number; totalGallons: number };
|
||||
time: { workedMinutesToday: number; isClockedIn: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the "today" snapshot for a single worker.
|
||||
* `date` defaults to the local date in the admin's TZ (we use UTC
|
||||
* midnight as a coarse cutoff; callers can pass a date they want).
|
||||
*/
|
||||
export async function getRouteTimeSummary(
|
||||
brandId: string,
|
||||
workerId: string,
|
||||
date?: string,
|
||||
): Promise<RouteTimeSummary | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const day = date ?? new Date().toISOString().slice(0, 10);
|
||||
const start = new Date(`${day}T00:00:00.000Z`);
|
||||
const end = new Date(start);
|
||||
end.setUTCDate(end.getUTCDate() + 1);
|
||||
|
||||
const worker = await withBrand(brandId, async (tx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
eq(fieldWorkers.id, workerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
});
|
||||
if (!worker) return null;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// Water totals for the day
|
||||
const waterRes = await pool.query<{
|
||||
entry_count: string;
|
||||
total_gallons: string | null;
|
||||
headgates: string[] | null;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*)::text AS entry_count,
|
||||
COALESCE(SUM(total_gallons), 0)::text AS total_gallons,
|
||||
ARRAY_AGG(DISTINCT headgate_id::text) FILTER (WHERE headgate_id IS NOT NULL) AS headgates
|
||||
FROM water_log_entries
|
||||
WHERE brand_id = $1
|
||||
AND field_worker_id = $2
|
||||
AND logged_at >= $3
|
||||
AND logged_at < $4`,
|
||||
[brandId, workerId, start.toISOString(), end.toISOString()],
|
||||
);
|
||||
const wRow = waterRes.rows[0];
|
||||
|
||||
// Time totals for the day — read from time_tracking_logs directly,
|
||||
// aggregating minutes from clock_in → (clock_out OR now). Counts an
|
||||
// open log as in-progress; does NOT mutate.
|
||||
const timeRes = await pool.query<{
|
||||
open_log_id: string | null;
|
||||
open_clock_in: string | null;
|
||||
last_clock_out: string | null;
|
||||
worked_minutes: string;
|
||||
break_minutes: string;
|
||||
}>(
|
||||
`SELECT
|
||||
MAX(id) FILTER (WHERE clock_out IS NULL) AS open_log_id,
|
||||
MAX(clock_in) FILTER (WHERE clock_out IS NULL) AS open_clock_in,
|
||||
MAX(clock_out) AS last_clock_out,
|
||||
COALESCE(SUM(
|
||||
EXTRACT(EPOCH FROM (
|
||||
COALESCE(clock_out, NOW()) - clock_in
|
||||
)) / 60
|
||||
), 0)::text - COALESCE(SUM(lunch_minutes), 0)::text AS worked_minutes,
|
||||
COALESCE(SUM(lunch_minutes), 0)::text AS break_minutes
|
||||
FROM time_tracking_logs
|
||||
WHERE brand_id = $1
|
||||
AND field_worker_id = $2
|
||||
AND clock_in >= $3
|
||||
AND clock_in < $4`,
|
||||
[brandId, workerId, start.toISOString(), end.toISOString()],
|
||||
);
|
||||
const tRow = timeRes.rows[0];
|
||||
|
||||
const settings = await getTimeTrackingSettings(brandId);
|
||||
const dailyOtMin = settings?.daily_overtime_threshold ?? 480;
|
||||
const workedMin = Math.max(0, Number(tRow?.worked_minutes ?? 0));
|
||||
const breakMin = Math.max(0, Number(tRow?.break_minutes ?? 0));
|
||||
const regularMin = Math.min(workedMin, dailyOtMin);
|
||||
const otMin = Math.max(0, workedMin - dailyOtMin);
|
||||
|
||||
// Find the timesheet (if any) that contains this date
|
||||
const tsRes = await pool.query<{ id: string; status: TimesheetStatus }>(
|
||||
`SELECT id, status
|
||||
FROM time_tracking_timesheets
|
||||
WHERE brand_id = $1
|
||||
AND field_worker_id = $2
|
||||
AND period_start <= $3::date
|
||||
AND period_end >= $3::date
|
||||
LIMIT 1`,
|
||||
[brandId, workerId, day],
|
||||
);
|
||||
|
||||
return {
|
||||
date: day,
|
||||
workerId,
|
||||
workerName: worker.name || worker.pinHash.slice(0, 6) || "Worker",
|
||||
water: {
|
||||
entryCount: Number(wRow?.entry_count ?? 0),
|
||||
totalGallons: Number(wRow?.total_gallons ?? 0),
|
||||
headgatesVisited: (wRow?.headgates ?? []).filter(Boolean) as string[],
|
||||
},
|
||||
time: {
|
||||
clockedIn: tRow?.open_log_id != null,
|
||||
currentLogId: tRow?.open_log_id ?? null,
|
||||
clockInAt: tRow?.open_clock_in ?? null,
|
||||
lastClockOutAt: tRow?.last_clock_out ?? null,
|
||||
workedMinutesToday: workedMin,
|
||||
breakMinutesToday: breakMin,
|
||||
overtimeMinutesToday: otMin,
|
||||
regularMinutesToday: regularMin,
|
||||
timesheetStatus: tsRes.rows[0]?.status ?? null,
|
||||
timesheetId: tsRes.rows[0]?.id ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet view: every active worker in the brand for "today". Used by
|
||||
* the admin dashboard row so the dispatcher can see at a glance who's
|
||||
* on the route AND whether they're on the clock.
|
||||
*/
|
||||
export async function getFleetRouteSummary(
|
||||
brandId: string,
|
||||
date?: string,
|
||||
): Promise<WorkerRouteRow[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const day = date ?? new Date().toISOString().slice(0, 10);
|
||||
const start = new Date(`${day}T00:00:00.000Z`);
|
||||
const end = new Date(start);
|
||||
end.setUTCDate(end.getUTCDate() + 1);
|
||||
|
||||
const pool = getPool();
|
||||
// Cycle 10 — `field_workers` uses `name` and `pin_hash` (NOT the legacy
|
||||
// `display_name` / `pin` columns from `admin_users` / the pre-cycle-10
|
||||
// `time_tracking_workers`). Referencing the wrong columns crashes the
|
||||
// /admin/time-tracking overview page at render time.
|
||||
const { rows } = await pool.query<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
pin_hash: string;
|
||||
active: boolean;
|
||||
water_entries: string;
|
||||
water_gallons: string | null;
|
||||
worked_minutes: string;
|
||||
is_clocked_in: boolean;
|
||||
}>(
|
||||
`SELECT
|
||||
fw.id,
|
||||
fw.name,
|
||||
fw.pin_hash,
|
||||
fw.active,
|
||||
COALESCE(w.cnt, 0)::text AS water_entries,
|
||||
COALESCE(w.gal, 0)::text AS water_gallons,
|
||||
COALESCE(t.minutes, 0)::text AS worked_minutes,
|
||||
COALESCE(t.clocked_in, FALSE) AS is_clocked_in
|
||||
FROM field_workers fw
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS cnt, SUM(total_gallons) AS gal
|
||||
FROM water_log_entries
|
||||
WHERE brand_id = $1
|
||||
AND field_worker_id = fw.id
|
||||
AND logged_at >= $2
|
||||
AND logged_at < $3
|
||||
) w ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(
|
||||
EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60
|
||||
- COALESCE(lunch_minutes, 0)
|
||||
) AS minutes,
|
||||
BOOL_OR(clock_out IS NULL) AS clocked_in
|
||||
FROM time_tracking_logs
|
||||
WHERE brand_id = $1
|
||||
AND field_worker_id = fw.id
|
||||
AND clock_in >= $2
|
||||
AND clock_in < $3
|
||||
) t ON TRUE
|
||||
WHERE fw.brand_id = $1
|
||||
ORDER BY fw.name NULLS LAST, fw.id`,
|
||||
[brandId, start.toISOString(), end.toISOString()],
|
||||
);
|
||||
|
||||
// `pin_hash` is a scrypt self-describing hash — never display more
|
||||
// than a short fingerprint as a last-resort fallback for missing
|
||||
// worker names. The hash is sensitive but its first 6 chars are not
|
||||
// reversible, which matches the original code's intent.
|
||||
return rows.map((r) => ({
|
||||
workerId: r.id,
|
||||
workerName: r.name || r.pin_hash.slice(0, 6) || "Worker",
|
||||
active: r.active,
|
||||
water: {
|
||||
entryCount: Number(r.water_entries ?? 0),
|
||||
totalGallons: Number(r.water_gallons ?? 0),
|
||||
},
|
||||
time: {
|
||||
workedMinutesToday: Math.max(0, Number(r.worked_minutes ?? 0)),
|
||||
isClockedIn: r.is_clocked_in,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
/**
|
||||
* Time Tracking — Timesheets server actions.
|
||||
*
|
||||
* Cycle 12 (Phase 2): per-worker pay-period timesheets with a Draft →
|
||||
* Submitted → Approved/Locked workflow. Only `platform_admin` /
|
||||
* `brand_admin` can unlock a locked timesheet, and that requires a
|
||||
* mandatory audit note.
|
||||
*
|
||||
* Authorisation model:
|
||||
* - platform_admin / brand_admin — full access across their brand
|
||||
* - supervisor (field_workers.role = 'supervisor') — read + approve
|
||||
* timesheets for assigned supervisees only
|
||||
* - worker (field_workers.role IN ('worker','driver')) — can only
|
||||
* read their own timesheets and submit (draft → submitted); cannot
|
||||
* approve / unlock
|
||||
*
|
||||
* Concurrency:
|
||||
* - All mutations run inside a single `withTx` block so the timesheet
|
||||
* status + the audit row + the recompute RPC commit atomically.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
lte,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPool, withTx } from "@/lib/db";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
timeTrackingLogs,
|
||||
timeTrackingTimesheets,
|
||||
timeTrackingSupervisorAssignments,
|
||||
timeTrackingAuditLog,
|
||||
type TimesheetStatus,
|
||||
} from "@/db/schema/time-tracking";
|
||||
import { fieldWorkers } from "@/db/schema/field-workers";
|
||||
import {
|
||||
logTimeTrackingEventInTx,
|
||||
} from "@/lib/time-tracking-audit";
|
||||
import { payPeriodForDate, toIsoDate } from "@/lib/time-tracking/period";
|
||||
|
||||
// ── Period math ────────────────────────────────────────────────────────────
|
||||
// `payPeriodForDate` / `toIsoDate` live in `@/lib/time-tracking/period`
|
||||
// so client components can use them too — server-action files
|
||||
// ("use server") can only export async functions.
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimesheetEntry = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
worker_name: string;
|
||||
task_id: string | null;
|
||||
task_name: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
lunch_break_minutes: number;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
entry_kind: "clock" | "manual";
|
||||
manual_reason: string | null;
|
||||
gps_verified: boolean;
|
||||
clock_in_lat: number | null;
|
||||
clock_in_lng: number | null;
|
||||
clock_in_accuracy_m: number | null;
|
||||
clock_out_lat: number | null;
|
||||
clock_out_lng: number | null;
|
||||
clock_out_accuracy_m: number | null;
|
||||
edited_at: string | null;
|
||||
edited_by_label: string | null;
|
||||
edit_reason: string | null;
|
||||
total_minutes: number;
|
||||
};
|
||||
|
||||
export type TimesheetSummary = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
worker_name: string;
|
||||
worker_role: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
status: TimesheetStatus;
|
||||
total_minutes: number;
|
||||
regular_minutes: number;
|
||||
overtime_minutes: number;
|
||||
break_minutes: number;
|
||||
entry_count: number;
|
||||
daily_totals: Record<string, number>;
|
||||
submitted_at: string | null;
|
||||
submitted_notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
reviewed_by_label: string | null;
|
||||
reviewer_comment: string | null;
|
||||
rejected_at: string | null;
|
||||
rejection_reason: string | null;
|
||||
locked_at: string | null;
|
||||
unlocked_at: string | null;
|
||||
unlock_audit_note: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AuditEntry = {
|
||||
id: string;
|
||||
actor_id: string | null;
|
||||
actor_label: string;
|
||||
actor_kind: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
type CallerKind = "platform_admin" | "brand_admin" | "supervisor" | "worker";
|
||||
|
||||
type CallerContext = {
|
||||
adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>>;
|
||||
kind: CallerKind;
|
||||
brandId: string;
|
||||
/** When kind === 'supervisor', the field_worker row id of the supervisor. */
|
||||
supervisorFieldWorkerId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the caller's role + brand context. Admins (platform/brand) are
|
||||
* detected by `admin_users.role`. Supervisors are detected by matching
|
||||
* `field_workers.role = 'supervisor'` for the admin's email.
|
||||
*/
|
||||
async function resolveCaller(brandId?: string): Promise<CallerContext | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const resolvedBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
if (!resolvedBrandId) return null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return {
|
||||
adminUser,
|
||||
kind: "platform_admin",
|
||||
brandId: resolvedBrandId,
|
||||
supervisorFieldWorkerId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (adminUser.role === "brand_admin") {
|
||||
return {
|
||||
adminUser,
|
||||
kind: "brand_admin",
|
||||
brandId: resolvedBrandId,
|
||||
supervisorFieldWorkerId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// store_employee or other: lookup as supervisor via email match
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const fw = await db
|
||||
.select()
|
||||
.from(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, adminUser.brand_id ?? ""),
|
||||
sql`LOWER(${fieldWorkers.name}) = LOWER(${adminUser.email ?? ""})`,
|
||||
eq(fieldWorkers.role, "supervisor"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (fw.length > 0) {
|
||||
return {
|
||||
adminUser,
|
||||
kind: "supervisor" as const,
|
||||
brandId: resolvedBrandId,
|
||||
supervisorFieldWorkerId: fw[0].id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
adminUser,
|
||||
kind: "worker" as const,
|
||||
brandId: resolvedBrandId,
|
||||
supervisorFieldWorkerId: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── List timesheets ────────────────────────────────────────────────────────
|
||||
|
||||
export async function listTimesheets(
|
||||
brandId: string,
|
||||
options: {
|
||||
status?: TimesheetStatus | "all";
|
||||
workerId?: string;
|
||||
periodStart?: string; // YYYY-MM-DD
|
||||
periodEnd?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
): Promise<{ rows: TimesheetSummary[]; total: number }> {
|
||||
const caller = await resolveCaller(brandId);
|
||||
if (!caller) return { rows: [], total: 0 };
|
||||
|
||||
return withBrand(caller.brandId, async (db) => {
|
||||
const conds: SQL[] = [eq(timeTrackingTimesheets.brandId, caller.brandId)];
|
||||
if (options.status && options.status !== "all") {
|
||||
conds.push(
|
||||
eq(timeTrackingTimesheets.status, options.status as TimesheetStatus),
|
||||
);
|
||||
}
|
||||
if (options.workerId) {
|
||||
conds.push(eq(timeTrackingTimesheets.fieldWorkerId, options.workerId));
|
||||
}
|
||||
if (options.periodStart) {
|
||||
conds.push(gte(timeTrackingTimesheets.periodStart, options.periodStart));
|
||||
}
|
||||
if (options.periodEnd) {
|
||||
conds.push(lte(timeTrackingTimesheets.periodEnd, options.periodEnd));
|
||||
}
|
||||
|
||||
// Supervisor scoping: only their assigned supervisees
|
||||
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||
const assigned = await db
|
||||
.select({
|
||||
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||
})
|
||||
.from(timeTrackingSupervisorAssignments)
|
||||
.where(
|
||||
eq(
|
||||
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
|
||||
caller.supervisorFieldWorkerId,
|
||||
),
|
||||
);
|
||||
const ids = assigned.map((a) => a.id);
|
||||
if (ids.length === 0) return { rows: [], total: 0 };
|
||||
conds.push(inArray(timeTrackingTimesheets.fieldWorkerId, ids));
|
||||
}
|
||||
|
||||
const limit = Math.min(options.limit ?? 100, 500);
|
||||
const offset = Math.max(options.offset ?? 0, 0);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: timeTrackingTimesheets.id,
|
||||
brand_id: timeTrackingTimesheets.brandId,
|
||||
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
|
||||
worker_name: fieldWorkers.name,
|
||||
worker_role: fieldWorkers.role,
|
||||
period_start: timeTrackingTimesheets.periodStart,
|
||||
period_end: timeTrackingTimesheets.periodEnd,
|
||||
status: timeTrackingTimesheets.status,
|
||||
total_minutes: timeTrackingTimesheets.totalMinutes,
|
||||
regular_minutes: timeTrackingTimesheets.regularMinutes,
|
||||
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
|
||||
break_minutes: timeTrackingTimesheets.breakMinutes,
|
||||
entry_count: timeTrackingTimesheets.entryCount,
|
||||
daily_totals: timeTrackingTimesheets.dailyTotals,
|
||||
submitted_at: timeTrackingTimesheets.submittedAt,
|
||||
submitted_notes: timeTrackingTimesheets.submittedNotes,
|
||||
reviewed_at: timeTrackingTimesheets.reviewedAt,
|
||||
reviewer_comment: timeTrackingTimesheets.reviewerComment,
|
||||
rejected_at: timeTrackingTimesheets.rejectedAt,
|
||||
rejection_reason: timeTrackingTimesheets.rejectionReason,
|
||||
locked_at: timeTrackingTimesheets.lockedAt,
|
||||
unlocked_at: timeTrackingTimesheets.unlockedAt,
|
||||
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
|
||||
updated_at: timeTrackingTimesheets.updatedAt,
|
||||
})
|
||||
.from(timeTrackingTimesheets)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
|
||||
)
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(timeTrackingTimesheets.periodStart))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalRes = await db
|
||||
.select({ count: sql<number>`COUNT(*)::int` })
|
||||
.from(timeTrackingTimesheets)
|
||||
.where(and(...conds));
|
||||
|
||||
return {
|
||||
rows: rows.map((r) =>
|
||||
rowToSummary({
|
||||
...r,
|
||||
daily_totals: (r.daily_totals ?? {}) as Record<string, number>,
|
||||
reviewed_by_label: null,
|
||||
}),
|
||||
),
|
||||
total: totalRes[0]?.count ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function rowToSummary(r: {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
worker_name: string | null;
|
||||
worker_role: string | null;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
status: TimesheetStatus;
|
||||
total_minutes: number;
|
||||
regular_minutes: number;
|
||||
overtime_minutes: number;
|
||||
break_minutes: number;
|
||||
entry_count: number;
|
||||
daily_totals: Record<string, number> | null;
|
||||
submitted_at: Date | null;
|
||||
submitted_notes: string | null;
|
||||
reviewed_at: Date | null;
|
||||
reviewer_comment: string | null;
|
||||
rejected_at: Date | null;
|
||||
rejection_reason: string | null;
|
||||
locked_at: Date | null;
|
||||
unlocked_at: Date | null;
|
||||
unlock_audit_note: string | null;
|
||||
updated_at: Date;
|
||||
reviewed_by_label: string | null;
|
||||
}): TimesheetSummary {
|
||||
return {
|
||||
id: r.id,
|
||||
brand_id: r.brand_id,
|
||||
field_worker_id: r.field_worker_id,
|
||||
worker_name: r.worker_name ?? "(unknown)",
|
||||
worker_role: r.worker_role ?? "worker",
|
||||
period_start: r.period_start,
|
||||
period_end: r.period_end,
|
||||
status: r.status,
|
||||
total_minutes: r.total_minutes,
|
||||
regular_minutes: r.regular_minutes,
|
||||
overtime_minutes: r.overtime_minutes,
|
||||
break_minutes: r.break_minutes,
|
||||
entry_count: r.entry_count,
|
||||
daily_totals: r.daily_totals ?? {},
|
||||
submitted_at: r.submitted_at ? r.submitted_at.toISOString() : null,
|
||||
submitted_notes: r.submitted_notes,
|
||||
reviewed_at: r.reviewed_at ? r.reviewed_at.toISOString() : null,
|
||||
reviewed_by_label: r.reviewed_by_label,
|
||||
reviewer_comment: r.reviewer_comment,
|
||||
rejected_at: r.rejected_at ? r.rejected_at.toISOString() : null,
|
||||
rejection_reason: r.rejection_reason,
|
||||
locked_at: r.locked_at ? r.locked_at.toISOString() : null,
|
||||
unlocked_at: r.unlocked_at ? r.unlocked_at.toISOString() : null,
|
||||
unlock_audit_note: r.unlock_audit_note,
|
||||
updated_at: r.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Get a single timesheet (with entries + audit) ─────────────────────────
|
||||
|
||||
export async function getTimesheet(
|
||||
timesheetId: string,
|
||||
): Promise<{
|
||||
summary: TimesheetSummary;
|
||||
entries: TimesheetEntry[];
|
||||
audit: AuditEntry[];
|
||||
} | null> {
|
||||
const caller = await resolveCaller();
|
||||
if (!caller) return null;
|
||||
|
||||
return withBrand(caller.brandId, async (db) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: timeTrackingTimesheets.id,
|
||||
brand_id: timeTrackingTimesheets.brandId,
|
||||
field_worker_id: timeTrackingTimesheets.fieldWorkerId,
|
||||
worker_name: fieldWorkers.name,
|
||||
worker_role: fieldWorkers.role,
|
||||
period_start: timeTrackingTimesheets.periodStart,
|
||||
period_end: timeTrackingTimesheets.periodEnd,
|
||||
status: timeTrackingTimesheets.status,
|
||||
total_minutes: timeTrackingTimesheets.totalMinutes,
|
||||
regular_minutes: timeTrackingTimesheets.regularMinutes,
|
||||
overtime_minutes: timeTrackingTimesheets.overtimeMinutes,
|
||||
break_minutes: timeTrackingTimesheets.breakMinutes,
|
||||
entry_count: timeTrackingTimesheets.entryCount,
|
||||
daily_totals: timeTrackingTimesheets.dailyTotals,
|
||||
submitted_at: timeTrackingTimesheets.submittedAt,
|
||||
submitted_notes: timeTrackingTimesheets.submittedNotes,
|
||||
reviewed_at: timeTrackingTimesheets.reviewedAt,
|
||||
reviewer_comment: timeTrackingTimesheets.reviewerComment,
|
||||
rejected_at: timeTrackingTimesheets.rejectedAt,
|
||||
rejection_reason: timeTrackingTimesheets.rejectionReason,
|
||||
locked_at: timeTrackingTimesheets.lockedAt,
|
||||
unlocked_at: timeTrackingTimesheets.unlockedAt,
|
||||
unlock_audit_note: timeTrackingTimesheets.unlockAuditNote,
|
||||
updated_at: timeTrackingTimesheets.updatedAt,
|
||||
})
|
||||
.from(timeTrackingTimesheets)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingTimesheets.fieldWorkerId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingTimesheets.id, timesheetId),
|
||||
eq(timeTrackingTimesheets.brandId, caller.brandId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
|
||||
// Auth scope check for supervisors
|
||||
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||
const assigned = await db
|
||||
.select({
|
||||
id: timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||
})
|
||||
.from(timeTrackingSupervisorAssignments)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
timeTrackingSupervisorAssignments.supervisorFieldWorkerId,
|
||||
caller.supervisorFieldWorkerId,
|
||||
),
|
||||
eq(
|
||||
timeTrackingSupervisorAssignments.superviseeFieldWorkerId,
|
||||
row.field_worker_id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (assigned.length === 0) return null;
|
||||
}
|
||||
|
||||
// Entries
|
||||
const entryRows = await db
|
||||
.select({
|
||||
id: timeTrackingLogs.id,
|
||||
brand_id: timeTrackingLogs.brandId,
|
||||
field_worker_id: timeTrackingLogs.fieldWorkerId,
|
||||
worker_name: fieldWorkers.name,
|
||||
task_id: timeTrackingLogs.taskId,
|
||||
task_name: timeTrackingLogs.taskName,
|
||||
clock_in: timeTrackingLogs.clockIn,
|
||||
clock_out: timeTrackingLogs.clockOut,
|
||||
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
|
||||
notes: timeTrackingLogs.notes,
|
||||
submitted_via: timeTrackingLogs.submittedVia,
|
||||
entry_kind: timeTrackingLogs.entryKind,
|
||||
manual_reason: timeTrackingLogs.manualReason,
|
||||
gps_verified: timeTrackingLogs.gpsVerified,
|
||||
clock_in_lat: timeTrackingLogs.clockInLat,
|
||||
clock_in_lng: timeTrackingLogs.clockInLng,
|
||||
clock_in_accuracy_m: timeTrackingLogs.clockInAccuracyM,
|
||||
clock_out_lat: timeTrackingLogs.clockOutLat,
|
||||
clock_out_lng: timeTrackingLogs.clockOutLng,
|
||||
clock_out_accuracy_m: timeTrackingLogs.clockOutAccuracyM,
|
||||
edited_at: timeTrackingLogs.editedAt,
|
||||
edit_reason: timeTrackingLogs.editReason,
|
||||
})
|
||||
.from(timeTrackingLogs)
|
||||
.leftJoin(
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingLogs.brandId, caller.brandId),
|
||||
eq(timeTrackingLogs.fieldWorkerId, row.field_worker_id),
|
||||
gte(timeTrackingLogs.clockIn, new Date(row.period_start)),
|
||||
lte(timeTrackingLogs.clockIn, endOfDay(row.period_end)),
|
||||
),
|
||||
)
|
||||
.orderBy(timeTrackingLogs.clockIn);
|
||||
|
||||
const entryIds = entryRows.map((e) => e.id);
|
||||
const auditRows = entryIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(timeTrackingAuditLog)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingAuditLog.brandId, caller.brandId),
|
||||
sql`(${timeTrackingAuditLog.entityType} = 'timesheet' AND ${timeTrackingAuditLog.entityId} = ${timesheetId}) OR (${timeTrackingAuditLog.entityType} = 'log' AND ${timeTrackingAuditLog.entityId} IN (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)}))`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(timeTrackingAuditLog.createdAt))
|
||||
.limit(200)
|
||||
: [];
|
||||
|
||||
return {
|
||||
summary: rowToSummary({
|
||||
...row,
|
||||
daily_totals: (row.daily_totals ?? {}) as Record<string, number>,
|
||||
reviewed_by_label: null,
|
||||
}),
|
||||
entries: entryRows.map(
|
||||
(e): TimesheetEntry => ({
|
||||
id: e.id,
|
||||
brand_id: e.brand_id,
|
||||
field_worker_id: e.field_worker_id,
|
||||
worker_name: e.worker_name ?? "(unknown)",
|
||||
task_id: e.task_id,
|
||||
task_name: e.task_name,
|
||||
clock_in: e.clock_in.toISOString(),
|
||||
clock_out: e.clock_out ? e.clock_out.toISOString() : null,
|
||||
lunch_break_minutes: e.lunch_break_minutes,
|
||||
notes: e.notes,
|
||||
submitted_via: e.submitted_via,
|
||||
entry_kind: e.entry_kind,
|
||||
manual_reason: e.manual_reason,
|
||||
gps_verified: e.gps_verified,
|
||||
clock_in_lat: e.clock_in_lat,
|
||||
clock_in_lng: e.clock_in_lng,
|
||||
clock_in_accuracy_m: e.clock_in_accuracy_m,
|
||||
clock_out_lat: e.clock_out_lat,
|
||||
clock_out_lng: e.clock_out_lng,
|
||||
clock_out_accuracy_m: e.clock_out_accuracy_m,
|
||||
edited_at: e.edited_at ? e.edited_at.toISOString() : null,
|
||||
edited_by_label: null,
|
||||
edit_reason: e.edit_reason,
|
||||
total_minutes: computeMinutes(
|
||||
e.clock_in,
|
||||
e.clock_out,
|
||||
e.lunch_break_minutes,
|
||||
),
|
||||
}),
|
||||
),
|
||||
audit: auditRows.map((a) => ({
|
||||
id: a.id,
|
||||
actor_id: a.actorId,
|
||||
actor_label: a.actorLabel,
|
||||
actor_kind: a.actorKind,
|
||||
action: a.action,
|
||||
entity_type: a.entityType,
|
||||
entity_id: a.entityId,
|
||||
details: (a.details as Record<string, unknown>) ?? {},
|
||||
created_at: a.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function endOfDay(iso: string): Date {
|
||||
const d = new Date(iso);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
function computeMinutes(
|
||||
clockIn: Date,
|
||||
clockOut: Date | null,
|
||||
lunchMinutes: number,
|
||||
): number {
|
||||
if (!clockOut) return 0;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((clockOut.getTime() - clockIn.getTime()) / 60000) - lunchMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ensure the timesheet exists for a (worker, period) ────────────────────
|
||||
|
||||
export async function ensureTimesheetForPeriod(
|
||||
brandId: string,
|
||||
workerId: string,
|
||||
periodStart: string,
|
||||
periodEnd: string,
|
||||
): Promise<string> {
|
||||
const caller = await resolveCaller(brandId);
|
||||
if (!caller) throw new Error("Not authenticated");
|
||||
|
||||
const pool = getPool();
|
||||
const res = await pool.query<{ get_or_create_timesheet: string }>(
|
||||
"SELECT get_or_create_timesheet($1::uuid, $2::uuid, $3::date, $4::date) AS get_or_create_timesheet",
|
||||
[brandId, workerId, periodStart, periodEnd],
|
||||
);
|
||||
return res.rows[0]?.get_or_create_timesheet ?? "";
|
||||
}
|
||||
|
||||
// ── Submit, approve, reject, unlock ────────────────────────────────────────
|
||||
|
||||
const SubmitSchema = z.object({
|
||||
timesheetId: z.string().uuid(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function submitTimesheet(
|
||||
timesheetId: string,
|
||||
notes?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const caller = await resolveCaller();
|
||||
if (!caller) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const parsed = SubmitSchema.safeParse({ timesheetId, notes });
|
||||
if (!parsed.success) return { success: false, error: "Invalid input" };
|
||||
|
||||
return withTx(async (client) => {
|
||||
const ts = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT brand_id, field_worker_id, status
|
||||
FROM time_tracking_timesheets
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[timesheetId],
|
||||
);
|
||||
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||
const t = ts.rows[0];
|
||||
|
||||
if (t.status !== "draft" && t.status !== "rejected") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot submit a timesheet in status '${t.status}'`,
|
||||
};
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE time_tracking_timesheets
|
||||
SET status = 'submitted',
|
||||
submitted_at = NOW(),
|
||||
submitted_by = $2,
|
||||
submitted_notes = $3,
|
||||
rejected_at = NULL,
|
||||
rejected_by = NULL,
|
||||
rejection_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[timesheetId, caller.adminUser.id, notes ?? null],
|
||||
);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: t.brand_id,
|
||||
actorId: caller.adminUser.id,
|
||||
actorLabel:
|
||||
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||
action: "timesheet.submit",
|
||||
entityType: "timesheet",
|
||||
entityId: timesheetId,
|
||||
details: { notes: notes ?? null },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
const ApprovalSchema = z.object({
|
||||
timesheetId: z.string().uuid(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function approveTimesheet(
|
||||
timesheetId: string,
|
||||
comment?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const caller = await resolveCaller();
|
||||
if (!caller) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
caller.kind !== "platform_admin" &&
|
||||
caller.kind !== "brand_admin" &&
|
||||
caller.kind !== "supervisor"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const parsed = ApprovalSchema.safeParse({ timesheetId, comment });
|
||||
if (!parsed.success) return { success: false, error: "Invalid input" };
|
||||
|
||||
return withTx(async (client) => {
|
||||
const ts = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT brand_id, field_worker_id, status
|
||||
FROM time_tracking_timesheets
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[timesheetId],
|
||||
);
|
||||
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||
const t = ts.rows[0];
|
||||
|
||||
// Supervisor must be assigned to this worker
|
||||
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||
const ok = await client.query(
|
||||
`SELECT 1 FROM time_tracking_supervisor_assignments
|
||||
WHERE supervisor_field_worker_id = $1
|
||||
AND supervisee_field_worker_id = $2
|
||||
LIMIT 1`,
|
||||
[caller.supervisorFieldWorkerId, t.field_worker_id],
|
||||
);
|
||||
if (ok.rows.length === 0)
|
||||
return { success: false, error: "Not authorized for this worker" };
|
||||
}
|
||||
|
||||
if (t.status !== "submitted") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Only submitted timesheets can be approved (status='${t.status}')`,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await client.query(
|
||||
`UPDATE time_tracking_timesheets
|
||||
SET status = 'approved',
|
||||
reviewed_at = $2,
|
||||
reviewed_by = $3,
|
||||
reviewer_comment = $4,
|
||||
locked_at = $2,
|
||||
locked_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[timesheetId, now, caller.adminUser.id, comment ?? null],
|
||||
);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: t.brand_id,
|
||||
actorId: caller.adminUser.id,
|
||||
actorLabel:
|
||||
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||
action: "timesheet.approve",
|
||||
entityType: "timesheet",
|
||||
entityId: timesheetId,
|
||||
details: { comment: comment ?? null },
|
||||
});
|
||||
|
||||
// Recompute totals so the locked snapshot is accurate
|
||||
await client.query("SELECT recompute_timesheet_totals($1)", [timesheetId]);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
const RejectSchema = z.object({
|
||||
timesheetId: z.string().uuid(),
|
||||
reason: z.string().min(3).max(2000),
|
||||
});
|
||||
|
||||
export async function rejectTimesheet(
|
||||
timesheetId: string,
|
||||
reason: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const caller = await resolveCaller();
|
||||
if (!caller) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
caller.kind !== "platform_admin" &&
|
||||
caller.kind !== "brand_admin" &&
|
||||
caller.kind !== "supervisor"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const parsed = RejectSchema.safeParse({ timesheetId, reason });
|
||||
if (!parsed.success) return { success: false, error: "Reason is required" };
|
||||
|
||||
return withTx(async (client) => {
|
||||
const ts = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT brand_id, field_worker_id, status
|
||||
FROM time_tracking_timesheets
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[timesheetId],
|
||||
);
|
||||
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||
const t = ts.rows[0];
|
||||
|
||||
if (caller.kind === "supervisor" && caller.supervisorFieldWorkerId) {
|
||||
const ok = await client.query(
|
||||
`SELECT 1 FROM time_tracking_supervisor_assignments
|
||||
WHERE supervisor_field_worker_id = $1
|
||||
AND supervisee_field_worker_id = $2
|
||||
LIMIT 1`,
|
||||
[caller.supervisorFieldWorkerId, t.field_worker_id],
|
||||
);
|
||||
if (ok.rows.length === 0)
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (t.status !== "submitted") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Only submitted timesheets can be rejected (status='${t.status}')`,
|
||||
};
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE time_tracking_timesheets
|
||||
SET status = 'rejected',
|
||||
rejected_at = NOW(),
|
||||
rejected_by = $2,
|
||||
rejection_reason = $3,
|
||||
locked_at = NULL,
|
||||
locked_by = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[timesheetId, caller.adminUser.id, reason],
|
||||
);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: t.brand_id,
|
||||
actorId: caller.adminUser.id,
|
||||
actorLabel:
|
||||
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||
action: "timesheet.reject",
|
||||
entityType: "timesheet",
|
||||
entityId: timesheetId,
|
||||
details: { reason },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
const UnlockSchema = z.object({
|
||||
timesheetId: z.string().uuid(),
|
||||
auditNote: z.string().min(5).max(2000),
|
||||
});
|
||||
|
||||
export async function unlockTimesheet(
|
||||
timesheetId: string,
|
||||
auditNote: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const caller = await resolveCaller();
|
||||
if (!caller) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Only platform_admin or brand_admin can unlock a locked timesheet.
|
||||
if (caller.kind !== "platform_admin" && caller.kind !== "brand_admin") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only admins can unlock a locked timesheet",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = UnlockSchema.safeParse({ timesheetId, auditNote });
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
error: "An audit note of at least 5 characters is required",
|
||||
};
|
||||
|
||||
return withTx(async (client) => {
|
||||
const ts = await client.query<{
|
||||
brand_id: string;
|
||||
field_worker_id: string;
|
||||
status: string;
|
||||
locked_at: Date | null;
|
||||
}>(
|
||||
`SELECT brand_id, field_worker_id, status, locked_at
|
||||
FROM time_tracking_timesheets
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
[timesheetId],
|
||||
);
|
||||
if (ts.rows.length === 0) return { success: false, error: "Not found" };
|
||||
const t = ts.rows[0];
|
||||
|
||||
if (!t.locked_at) {
|
||||
return { success: false, error: "Timesheet is not currently locked" };
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE time_tracking_timesheets
|
||||
SET status = 'draft',
|
||||
locked_at = NULL,
|
||||
locked_by = NULL,
|
||||
unlocked_at = NOW(),
|
||||
unlocked_by = $2,
|
||||
unlock_audit_note = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[timesheetId, caller.adminUser.id, auditNote],
|
||||
);
|
||||
|
||||
await logTimeTrackingEventInTx(client, {
|
||||
brandId: t.brand_id,
|
||||
actorId: caller.adminUser.id,
|
||||
actorLabel:
|
||||
caller.adminUser.display_name ?? caller.adminUser.email ?? "admin",
|
||||
action: "timesheet.unlock",
|
||||
entityType: "timesheet",
|
||||
entityId: timesheetId,
|
||||
details: { audit_note: auditNote },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers for client components ──────────────────────────────────────────
|
||||
|
||||
/** Compute and return the [start, end] of the current pay period for a brand. */
|
||||
export async function getCurrentPayPeriod(
|
||||
brandId: string,
|
||||
): Promise<{ start: string; end: string }> {
|
||||
const { getTimeTrackingSettings } = await import("./index");
|
||||
const settings = await getTimeTrackingSettings(brandId);
|
||||
const { start, end } = payPeriodForDate(new Date(), settings);
|
||||
return { start: toIsoDate(start), end: toIsoDate(end) };
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
* 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log`
|
||||
* and runs brand-scoped via `withBrand()`.
|
||||
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
|
||||
* `water_admin_sessions` cookie; `requireWaterAdminSession()` is the
|
||||
* gate for those.
|
||||
* `wl_admin_session` cookie; `getAdminSession()` in
|
||||
* `@/lib/water-admin-pin-auth` is the gate for those.
|
||||
*
|
||||
* All actions return either `{ success: true, ... }` or
|
||||
* `{ success: false, error }`. We never throw across the action boundary
|
||||
@@ -20,12 +20,15 @@ import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
waterHeadgates,
|
||||
waterIrrigators,
|
||||
waterLogEntries,
|
||||
type WaterHeadgate,
|
||||
type WaterIrrigator,
|
||||
type WaterLogEntry,
|
||||
} from "@/db/schema/water-log";
|
||||
import {
|
||||
fieldWorkers,
|
||||
type FieldWorker,
|
||||
type FieldWorkerRole,
|
||||
} from "@/db/schema/field-workers";
|
||||
import { hashPin, generatePin } from "@/lib/water-log-pin";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
import {
|
||||
@@ -52,7 +55,10 @@ export type AdminHeadgate = {
|
||||
export type AdminIrrigator = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
// Workers are unified via `field_workers` (cycle 10). We still expose
|
||||
// only the water-domain roles here so the admin UI doesn't surface
|
||||
// time-only workers who can't interact with the water app.
|
||||
role: Extract<FieldWorkerRole, "irrigator" | "water_admin">;
|
||||
active: boolean;
|
||||
language_preference: string;
|
||||
phone: string | null;
|
||||
@@ -108,10 +114,11 @@ export type AlertLogEntry = {
|
||||
|
||||
// ── Auth helpers ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// `requireWaterAdminPermission` and `requireWaterAdminSession` live in
|
||||
// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`;
|
||||
// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither
|
||||
// calls the platform-level `getSession()` from `@/lib/auth`.
|
||||
// `requireWaterAdminPermission` lives in `@/actions/water-log/auth`
|
||||
// (site-admin gate via `getAdminUser()`). The brand-admin PIN gate is
|
||||
// `getAdminSession()` in `@/lib/water-admin-pin-auth` (cookie-only,
|
||||
// no DB session, no Neon Auth). Neither calls the platform-level
|
||||
// `getSession()` from `@/lib/auth`.
|
||||
|
||||
// Global hint used by the field admin portal: water log is currently
|
||||
// scoped to the Tuxedo brand only. Surfacing this in one place makes it
|
||||
@@ -141,7 +148,7 @@ function mapHeadgate(h: WaterHeadgate): AdminHeadgate {
|
||||
};
|
||||
}
|
||||
|
||||
function mapIrrigator(i: WaterIrrigator): AdminIrrigator {
|
||||
function mapIrrigator(i: FieldWorker): AdminIrrigator {
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
@@ -161,7 +168,7 @@ function mapEntry(
|
||||
return {
|
||||
id: e.id,
|
||||
headgate_id: e.headgateId,
|
||||
user_id: e.irrigatorId,
|
||||
user_id: e.fieldWorkerId,
|
||||
headgate_name: e.headgate_name,
|
||||
user_name: e.user_name,
|
||||
measurement: Number(e.measurement),
|
||||
@@ -368,9 +375,15 @@ const auth = await requireWaterAdminPermission();
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.brandId, brandId))
|
||||
.orderBy(desc(waterIrrigators.createdAt));
|
||||
.from(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
// Cycle 10: workers unified. Water admin UI only shows water-domain roles.
|
||||
sql`${fieldWorkers.role} IN ('irrigator', 'water_admin')`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fieldWorkers.createdAt));
|
||||
return rows.map(mapIrrigator);
|
||||
});
|
||||
}
|
||||
@@ -404,12 +417,16 @@ const auth = await requireWaterAdminPermission();
|
||||
|
||||
const result = await withBrand(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ id: waterIrrigators.id })
|
||||
.from(waterIrrigators)
|
||||
.select({ id: fieldWorkers.id })
|
||||
.from(fieldWorkers)
|
||||
.where(
|
||||
and(
|
||||
eq(waterIrrigators.brandId, brandId),
|
||||
eq(waterIrrigators.name, trimmed),
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
// Cycle 10: Partial unique index checks against `name` for
|
||||
// water-domain roles only; mirror that check here to keep the
|
||||
// "A user with that name already exists" UX identical.
|
||||
sql`lower(${fieldWorkers.name}) = lower(${trimmed})
|
||||
AND ${fieldWorkers.role} IN ('irrigator', 'water_admin')`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
@@ -417,7 +434,7 @@ const auth = await requireWaterAdminPermission();
|
||||
return { success: false as const, error: "A user with that name already exists" };
|
||||
}
|
||||
const [row] = await db
|
||||
.insert(waterIrrigators)
|
||||
.insert(fieldWorkers)
|
||||
.values({
|
||||
brandId,
|
||||
name: trimmed,
|
||||
@@ -454,7 +471,7 @@ return createWaterUser(brandId, name, "irrigator", language);
|
||||
}
|
||||
|
||||
export async function updateWaterIrrigator(
|
||||
irrigatorId: string,
|
||||
fieldWorkerId: string,
|
||||
name: string,
|
||||
active: boolean,
|
||||
language: string,
|
||||
@@ -465,7 +482,7 @@ export async function updateWaterIrrigator(
|
||||
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
|
||||
const brand = await withPlatformAdminBrandOfFieldWorker(fieldWorkerId);
|
||||
if (!brand) return { success: false, error: "User not found" };
|
||||
return withBrand(brand, async (db) => {
|
||||
const trimmed = name?.trim();
|
||||
@@ -474,7 +491,7 @@ const auth = await requireWaterAdminPermission();
|
||||
return { success: false, error: "Invalid language" };
|
||||
}
|
||||
const updated = await db
|
||||
.update(waterIrrigators)
|
||||
.update(fieldWorkers)
|
||||
.set({
|
||||
name: trimmed,
|
||||
active,
|
||||
@@ -483,7 +500,7 @@ const auth = await requireWaterAdminPermission();
|
||||
phone: phone ?? null,
|
||||
notes: notes ?? null,
|
||||
})
|
||||
.where(eq(waterIrrigators.id, irrigatorId))
|
||||
.where(eq(fieldWorkers.id, fieldWorkerId))
|
||||
.returning();
|
||||
if (!updated[0]) return { success: false, error: "User not found" };
|
||||
await logAuditEvent({
|
||||
@@ -492,7 +509,7 @@ const auth = await requireWaterAdminPermission();
|
||||
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||
action: "update",
|
||||
entityType: "user",
|
||||
entityId: irrigatorId,
|
||||
entityId: fieldWorkerId,
|
||||
details: { name: trimmed, active, role, language },
|
||||
});
|
||||
return { success: true };
|
||||
@@ -505,13 +522,13 @@ export async function deleteWaterUser(
|
||||
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
||||
const brand = await withPlatformAdminBrandOfFieldWorker(userId);
|
||||
if (!brand) return { success: false, error: "User not found" };
|
||||
return withBrand(brand, async (db) => {
|
||||
const deleted = await db
|
||||
.delete(waterIrrigators)
|
||||
.where(eq(waterIrrigators.id, userId))
|
||||
.returning({ id: waterIrrigators.id });
|
||||
.delete(fieldWorkers)
|
||||
.where(eq(fieldWorkers.id, userId))
|
||||
.returning({ id: fieldWorkers.id });
|
||||
if (!deleted[0]) return { success: false, error: "User not found" };
|
||||
await logAuditEvent({
|
||||
brandId: brand,
|
||||
@@ -531,20 +548,20 @@ export async function resetWaterIrrigatorPin(
|
||||
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
const brand = await withPlatformAdminBrandOfIrrigator(userId);
|
||||
const brand = await withPlatformAdminBrandOfFieldWorker(userId);
|
||||
if (!brand) return { success: false, error: "User not found" };
|
||||
const pin = generatePin();
|
||||
const pinHash = hashPin(pin);
|
||||
return withBrand(brand, async (db) => {
|
||||
const updated = await db
|
||||
.update(waterIrrigators)
|
||||
.update(fieldWorkers)
|
||||
.set({ pinHash })
|
||||
.where(eq(waterIrrigators.id, userId))
|
||||
.returning({ id: waterIrrigators.id });
|
||||
.where(eq(fieldWorkers.id, userId))
|
||||
.returning({ id: fieldWorkers.id });
|
||||
if (!updated[0]) return { success: false, error: "User not found" };
|
||||
// Invalidate existing sessions for safety
|
||||
await db.execute(
|
||||
sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`,
|
||||
sql`DELETE FROM water_sessions WHERE field_worker_id = ${userId}`,
|
||||
);
|
||||
await logAuditEvent({
|
||||
brandId: brand,
|
||||
@@ -572,7 +589,7 @@ const auth = await requireWaterAdminPermission();
|
||||
const rows = await db.execute<{
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
irrigator_id: string;
|
||||
field_worker_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: string;
|
||||
@@ -586,15 +603,15 @@ const auth = await requireWaterAdminPermission();
|
||||
logged_date: string | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
e.id, e.headgate_id, e.irrigator_id,
|
||||
e.id, e.headgate_id, e.field_worker_id,
|
||||
h.name AS headgate_name,
|
||||
i.name AS user_name,
|
||||
fw.name AS user_name,
|
||||
e.measurement::text, e.unit,
|
||||
e.total_gallons::text, e.method, e.notes, e.submitted_via,
|
||||
e.photo_url, e.logged_at, e.logged_date
|
||||
FROM water_log_entries e
|
||||
JOIN water_headgates h ON h.id = e.headgate_id
|
||||
JOIN water_irrigators i ON i.id = e.irrigator_id
|
||||
JOIN field_workers fw ON fw.id = e.field_worker_id
|
||||
WHERE e.brand_id = ${brandId}
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT ${safeLimit}
|
||||
@@ -602,7 +619,7 @@ const auth = await requireWaterAdminPermission();
|
||||
return rows.rows.map((r) => ({
|
||||
id: r.id,
|
||||
headgate_id: r.headgate_id,
|
||||
user_id: r.irrigator_id,
|
||||
user_id: r.field_worker_id,
|
||||
headgate_name: r.headgate_name,
|
||||
user_name: r.user_name,
|
||||
measurement: Number(r.measurement),
|
||||
@@ -628,7 +645,7 @@ const auth = await requireWaterAdminPermission();
|
||||
const rows = await db.execute<{
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
irrigator_id: string;
|
||||
field_worker_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: string;
|
||||
@@ -642,15 +659,15 @@ const auth = await requireWaterAdminPermission();
|
||||
logged_date: string | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
e.id, e.headgate_id, e.irrigator_id,
|
||||
e.id, e.headgate_id, e.field_worker_id,
|
||||
h.name AS headgate_name,
|
||||
i.name AS user_name,
|
||||
fw.name AS user_name,
|
||||
e.measurement::text, e.unit,
|
||||
e.total_gallons::text, e.method, e.notes, e.submitted_via,
|
||||
e.photo_url, e.logged_at, e.logged_date
|
||||
FROM water_log_entries e
|
||||
JOIN water_headgates h ON h.id = e.headgate_id
|
||||
JOIN water_irrigators i ON i.id = e.irrigator_id
|
||||
JOIN field_workers fw ON fw.id = e.field_worker_id
|
||||
WHERE e.id = ${entryId}
|
||||
LIMIT 1
|
||||
`);
|
||||
@@ -659,7 +676,7 @@ const auth = await requireWaterAdminPermission();
|
||||
return {
|
||||
id: r.id,
|
||||
headgate_id: r.headgate_id,
|
||||
user_id: r.irrigator_id,
|
||||
user_id: r.field_worker_id,
|
||||
headgate_name: r.headgate_name,
|
||||
user_name: r.user_name,
|
||||
measurement: Number(r.measurement),
|
||||
@@ -769,7 +786,7 @@ const auth = await requireWaterAdminPermission();
|
||||
(
|
||||
SELECT i.name
|
||||
FROM water_log_entries e2
|
||||
JOIN water_irrigators i ON i.id = e2.irrigator_id
|
||||
JOIN field_workers fw ON fw.id = e2.field_worker_id
|
||||
WHERE e2.headgate_id = h.id
|
||||
ORDER BY e2.logged_at DESC
|
||||
LIMIT 1
|
||||
@@ -822,7 +839,7 @@ const auth = await requireWaterAdminPermission();
|
||||
const recent = await db.execute<{
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
irrigator_id: string;
|
||||
field_worker_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: string;
|
||||
@@ -836,15 +853,15 @@ const auth = await requireWaterAdminPermission();
|
||||
logged_date: string | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
e.id, e.headgate_id, e.irrigator_id,
|
||||
e.id, e.headgate_id, e.field_worker_id,
|
||||
h.name AS headgate_name,
|
||||
i.name AS user_name,
|
||||
fw.name AS user_name,
|
||||
e.measurement::text, e.unit,
|
||||
e.total_gallons::text, e.method, e.notes, e.submitted_via,
|
||||
e.photo_url, e.logged_at, e.logged_date
|
||||
FROM water_log_entries e
|
||||
JOIN water_headgates h ON h.id = e.headgate_id
|
||||
JOIN water_irrigators i ON i.id = e.irrigator_id
|
||||
JOIN field_workers fw ON fw.id = e.field_worker_id
|
||||
WHERE e.brand_id = ${brandId}
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT 10
|
||||
@@ -857,7 +874,7 @@ const auth = await requireWaterAdminPermission();
|
||||
recent_entries: recent.rows.map((r) => ({
|
||||
id: r.id,
|
||||
headgate_id: r.headgate_id,
|
||||
user_id: r.irrigator_id,
|
||||
user_id: r.field_worker_id,
|
||||
headgate_name: r.headgate_name,
|
||||
user_name: r.user_name,
|
||||
measurement: Number(r.measurement),
|
||||
@@ -943,13 +960,13 @@ async function withPlatformAdminBrandOf(headgateId: string): Promise<string | nu
|
||||
});
|
||||
}
|
||||
|
||||
async function withPlatformAdminBrandOfIrrigator(userId: string): Promise<string | null> {
|
||||
async function withPlatformAdminBrandOfFieldWorker(userId: string): Promise<string | null> {
|
||||
const { withPlatformAdmin } = await import("@/db/client");
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({ brandId: waterIrrigators.brandId })
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.id, userId))
|
||||
.select({ brandId: fieldWorkers.brandId })
|
||||
.from(fieldWorkers)
|
||||
.where(eq(fieldWorkers.id, userId))
|
||||
.limit(1);
|
||||
return rows[0]?.brandId ?? null;
|
||||
});
|
||||
|
||||
+53
-120
@@ -1,56 +1,55 @@
|
||||
/**
|
||||
* Water Log — auth layer.
|
||||
*
|
||||
* Centralizes the three auth gates the water-log module uses:
|
||||
* Centralizes the auth gates the water-log module uses:
|
||||
*
|
||||
* 1. `requireFieldSession()` — read wl_session cookie, look up
|
||||
* the session row in `water_sessions`,
|
||||
* return user/brand/role. Gates every
|
||||
* irrigator PIN action (/water).
|
||||
* 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look
|
||||
* up the row in `water_admin_sessions`.
|
||||
* Gates brand-admin PIN paths
|
||||
* (/water/admin/*).
|
||||
* 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
||||
* 2. `requireWaterAdminPermission()`— site-admin gate (Neon Auth +
|
||||
* admin_users.can_manage_water_log).
|
||||
* Gates /admin/water-log/*.
|
||||
*
|
||||
* **Crucially, the two PIN-only helpers (1) and (2) never call
|
||||
* `getSession()` from `@/lib/auth` and never call `getAdminUser()`.**
|
||||
* The water log module is intentionally PIN-based and self-contained;
|
||||
* a prior version of this code carried spurious `getSession()` calls
|
||||
* that hung PIN submission for users with no platform login. The static
|
||||
* "does not import getSession" test in `tests/unit/water-log-auth.test.ts`
|
||||
* guards against that regression.
|
||||
* **Crucially, the PIN-only helper (1) never calls `getSession()` from
|
||||
* `@/lib/auth` and never calls `getAdminUser()`.** The water log
|
||||
* module is intentionally PIN-based and self-contained; a prior
|
||||
* version of this code carried spurious `getSession()` calls that
|
||||
* hung PIN submission for users with no platform login. The static
|
||||
* "does not import getSession" test in
|
||||
* `tests/unit/water-log-auth.test.ts` guards against that regression.
|
||||
*
|
||||
* NOTE: Brand-admin PIN auth for `/water/admin/*` lives in
|
||||
* `src/lib/water-admin-pin-auth.ts` (cookie-only, no DB session,
|
||||
* no Neon Auth). It is intentionally a separate module so it can be
|
||||
* audited in isolation.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterSessions,
|
||||
waterIrrigators,
|
||||
waterAdminSessions,
|
||||
} from "@/db/schema/water-log";
|
||||
import { waterSessions } from "@/db/schema/water-log";
|
||||
import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import {
|
||||
WL_SESSION_COOKIE,
|
||||
type FieldSession,
|
||||
type FieldSessionResult,
|
||||
} from "@/lib/water-log/session-server";
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────
|
||||
|
||||
export type FieldSession =
|
||||
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type AdminPinSession =
|
||||
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type AdminPinSessionInfo = {
|
||||
sessionId: string;
|
||||
brandId: string;
|
||||
adminUserId: string;
|
||||
role: "water_admin";
|
||||
};
|
||||
// `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
|
||||
// so the `FieldSession | { ok: false, error }` shape is shared with
|
||||
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
|
||||
// surface. Consumers should import as:
|
||||
// `import type { FieldSession } from "@/lib/water-log/session-server";`
|
||||
// The previous `export type { FieldSession }` re-export here caused
|
||||
// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't
|
||||
// exist in target module" for pages that reference it indirectly
|
||||
// through server actions — so we removed the re-export and let
|
||||
// downstream consumers point at the source.
|
||||
|
||||
export type SiteAdminPermission =
|
||||
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
||||
@@ -62,7 +61,8 @@ export type SiteAdminPermission =
|
||||
* Field session gate — /water (irrigator PIN).
|
||||
*
|
||||
* Reads the `wl_session` cookie, joins it to `water_sessions` and the
|
||||
* linked `water_irrigators` row, and returns the user/brand/role.
|
||||
* linked `field_workers` row, and returns the user/brand/role.
|
||||
* (Cycle 10: workers are unified into `field_workers`.)
|
||||
*
|
||||
* Returns `{ ok: false, error }` for:
|
||||
* - missing cookie → "Not logged in"
|
||||
@@ -74,21 +74,21 @@ export type SiteAdminPermission =
|
||||
* Never calls `getSession()`. Never calls `getAdminUser()`. The only
|
||||
* network round-trip is the local DB query.
|
||||
*/
|
||||
export async function requireFieldSession(): Promise<FieldSession> {
|
||||
export async function requireFieldSession(): Promise<FieldSessionResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
const sessionId = cookieStore.get(WL_SESSION_COOKIE)?.value;
|
||||
if (!sessionId) return { ok: false, error: "Not logged in" };
|
||||
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
session: waterSessions,
|
||||
irrigator: waterIrrigators,
|
||||
worker: fieldWorkers,
|
||||
})
|
||||
.from(waterSessions)
|
||||
.innerJoin(
|
||||
waterIrrigators,
|
||||
eq(waterIrrigators.id, waterSessions.irrigatorId),
|
||||
fieldWorkers,
|
||||
eq(fieldWorkers.id, waterSessions.fieldWorkerId),
|
||||
)
|
||||
.where(eq(waterSessions.id, sessionId))
|
||||
.limit(1);
|
||||
@@ -103,85 +103,18 @@ export async function requireFieldSession(): Promise<FieldSession> {
|
||||
}
|
||||
return { ok: false as const, error: "Session expired" };
|
||||
}
|
||||
if (!row.irrigator.active) {
|
||||
if (!row.worker.active) {
|
||||
return { ok: false as const, error: "User is inactive" };
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
userId: row.irrigator.id,
|
||||
brandId: row.irrigator.brandId,
|
||||
role:
|
||||
(row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-admin PIN session gate — /water/admin/*.
|
||||
*
|
||||
* Reads the `wl_admin_session` cookie, joins it to
|
||||
* `water_admin_sessions`, and returns brand/admin info. Same error
|
||||
* semantics as `requireFieldSession` but for the admin-PIN cookie.
|
||||
*/
|
||||
export async function requireWaterAdminSession(): Promise<AdminPinSession> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) return { ok: false, error: "Not signed in" };
|
||||
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: waterAdminSessions.id,
|
||||
brandId: waterAdminSessions.brandId,
|
||||
adminUserId: waterAdminSessions.adminUserId,
|
||||
expiresAt: waterAdminSessions.expiresAt,
|
||||
})
|
||||
.from(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return { ok: false as const, error: "Session not found" };
|
||||
if (row.expiresAt.getTime() < Date.now()) {
|
||||
return { ok: false as const, error: "Session expired" };
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
sessionId: row.id,
|
||||
brandId: row.brandId,
|
||||
adminUserId: row.adminUserId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Return null on miss" variant of `requireWaterAdminSession`. Used by
|
||||
* pages that want to render conditionally based on whether the user
|
||||
* has a valid admin-PIN session, without forcing a hard error.
|
||||
*/
|
||||
export async function getWaterAdminSession(): Promise<AdminPinSessionInfo | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: waterAdminSessions.id,
|
||||
brandId: waterAdminSessions.brandId,
|
||||
adminUserId: waterAdminSessions.adminUserId,
|
||||
expiresAt: waterAdminSessions.expiresAt,
|
||||
})
|
||||
.from(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.expiresAt.getTime() <= Date.now()) return null;
|
||||
return {
|
||||
sessionId: row.id,
|
||||
brandId: row.brandId,
|
||||
adminUserId: row.adminUserId,
|
||||
role: "water_admin" as const,
|
||||
userId: row.worker.id,
|
||||
brandId: row.worker.brandId,
|
||||
// Cycle 10: workers unified, but a row reached via the water_sessions
|
||||
// FK is by construction a water-domain worker (`irrigator` or
|
||||
// `water_admin`). Narrow at the join boundary so FieldSessionAuthed
|
||||
// remains a tight union.
|
||||
role: row.worker.role as "irrigator" | "water_admin",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -195,18 +128,18 @@ export async function getFieldSessionUser(): Promise<{
|
||||
userId: string;
|
||||
name: string;
|
||||
brandId: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
role: FieldWorkerRole;
|
||||
} | null> {
|
||||
const s = await requireFieldSession();
|
||||
if (!s.ok) return null;
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: waterIrrigators.name,
|
||||
role: waterIrrigators.role,
|
||||
name: fieldWorkers.name,
|
||||
role: fieldWorkers.role,
|
||||
})
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.id, s.userId))
|
||||
.from(fieldWorkers)
|
||||
.where(eq(fieldWorkers.id, s.userId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
@@ -214,7 +147,7 @@ export async function getFieldSessionUser(): Promise<{
|
||||
userId: s.userId,
|
||||
name: row.name,
|
||||
brandId: s.brandId,
|
||||
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
role: row.role,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -225,7 +158,7 @@ export async function getFieldSessionUser(): Promise<{
|
||||
* This is the only helper in this module that calls
|
||||
* `getAdminUser()`, and that's intentional: it backs the
|
||||
* `/admin/water-log/*` pages which sit behind Neon Auth in the
|
||||
* middleware. The two PIN-only helpers above deliberately do not.
|
||||
* middleware. The PIN-only helper above deliberately does not.
|
||||
*/
|
||||
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
+111
-53
@@ -23,24 +23,37 @@ import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterHeadgates,
|
||||
waterIrrigators,
|
||||
waterSessions,
|
||||
waterLogEntries,
|
||||
waterAdminSessions,
|
||||
} from "@/db/schema/water-log";
|
||||
import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
|
||||
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||
import { logAlert } from "@/lib/water-log-audit";
|
||||
import {
|
||||
requireFieldSession,
|
||||
type FieldSession,
|
||||
} from "@/actions/water-log/auth";
|
||||
import {
|
||||
WL_SESSION_COOKIE,
|
||||
type FieldSession,
|
||||
clearSessionCookie,
|
||||
readSessionCookie,
|
||||
setSessionCookie,
|
||||
} from "@/lib/water-log/session-server";
|
||||
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
|
||||
import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
|
||||
|
||||
export type { FieldSession };
|
||||
// Re-export so existing call sites that import from
|
||||
// `@/actions/water-log/field` keep working.
|
||||
// Note: the re-export was moved to `@/lib/water-log/session-server`
|
||||
// in commit context; consumers that need `FieldSession` as a type
|
||||
// should now import it directly from `@/lib/water-log/session-server`.
|
||||
// This avoids Next.js 16.2's RSC bundler bug that treats type-only
|
||||
// re-exports through server-action wrappers as runtime exports.
|
||||
|
||||
// Field sessions last 8h — a working day in the field. Long enough
|
||||
// that a single sign-in covers a morning shift + afternoon shift.
|
||||
const FIELD_SESSION_HOURS = 8;
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +65,23 @@ type FieldHeadgate = {
|
||||
high_threshold: number | null;
|
||||
low_threshold: number | null;
|
||||
active: boolean;
|
||||
/**
|
||||
* Free-text headgate description. Often a field location / section
|
||||
* label (e.g. "Section 4 — North Field") or a coordinate string
|
||||
* ("GPS 36.1234, -119.5678"). The mobile UI shows this as the
|
||||
* card's secondary line — think of it as the "geocode" until a
|
||||
* proper location column ships.
|
||||
*/
|
||||
notes: string | null;
|
||||
/** ISO timestamp of the most recent successful log. Display only. */
|
||||
last_used_at: string | null;
|
||||
/**
|
||||
* Unit from the most recent log entry for this headgate, or
|
||||
* `null` if the headgate has never been logged against. Drives the
|
||||
* log-form unit picker — operators don't have to re-pick their
|
||||
* preferred unit on the same gate every shift.
|
||||
*/
|
||||
last_unit: string | null;
|
||||
};
|
||||
|
||||
type VerifyPinResult =
|
||||
@@ -59,7 +89,7 @@ type VerifyPinResult =
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
role: FieldWorkerRole;
|
||||
session_id: string;
|
||||
lang: string;
|
||||
}
|
||||
@@ -82,8 +112,33 @@ export async function getWaterHeadgates(
|
||||
eq(waterHeadgates.active, true),
|
||||
)
|
||||
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
|
||||
// Most-recent unit logged against this headgate. `null` when no
|
||||
// entries exist yet — fall back to `waterHeadgates.unit` on the
|
||||
// client. Per-headgate PK + the (logged_at DESC) index makes
|
||||
// this O(log n) per gate; for typical Tuxedo fleet sizes
|
||||
// (<200 active gates × handful of entries each), the planner
|
||||
// will hash-join on the headgate_id PK and never touch disk.
|
||||
const lastUnitSql = sql<string | null>`(
|
||||
SELECT ${waterLogEntries.unit}
|
||||
FROM ${waterLogEntries}
|
||||
WHERE ${waterLogEntries.headgateId} = ${waterHeadgates.id}
|
||||
AND ${waterLogEntries.brandId} = ${TUXEDO_BRAND_ID}
|
||||
ORDER BY ${waterLogEntries.loggedAt} DESC
|
||||
LIMIT 1
|
||||
)`;
|
||||
const rows = await db
|
||||
.select()
|
||||
.select({
|
||||
id: waterHeadgates.id,
|
||||
name: waterHeadgates.name,
|
||||
unit: waterHeadgates.unit,
|
||||
status: waterHeadgates.status,
|
||||
highThreshold: waterHeadgates.highThreshold,
|
||||
lowThreshold: waterHeadgates.lowThreshold,
|
||||
active: waterHeadgates.active,
|
||||
notes: waterHeadgates.notes,
|
||||
lastUsedAt: waterHeadgates.lastUsedAt,
|
||||
lastUnit: lastUnitSql,
|
||||
})
|
||||
.from(waterHeadgates)
|
||||
.where(where)
|
||||
.orderBy(waterHeadgates.name);
|
||||
@@ -95,6 +150,9 @@ export async function getWaterHeadgates(
|
||||
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
||||
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
||||
active: h.active,
|
||||
notes: h.notes,
|
||||
last_used_at: h.lastUsedAt ? h.lastUsedAt.toISOString() : null,
|
||||
last_unit: h.lastUnit,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -109,13 +167,16 @@ export async function verifyWaterPin(
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { success: false, error: formatError };
|
||||
|
||||
// Look the irrigator up across all brands (PIN is the only credential).
|
||||
// Look the worker up across all brands (PIN is the only credential).
|
||||
// Cycle 10: scans `field_workers` (the unified worker table from
|
||||
// `db/schema/field-workers.ts`). Both water and time workers live here
|
||||
// now; the role determines what they can do (water_admin vs worker).
|
||||
const lookup = await withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.active, true))
|
||||
.orderBy(waterIrrigators.name);
|
||||
.from(fieldWorkers)
|
||||
.where(eq(fieldWorkers.active, true))
|
||||
.orderBy(fieldWorkers.name);
|
||||
return rows;
|
||||
});
|
||||
|
||||
@@ -132,45 +193,48 @@ export async function verifyWaterPin(
|
||||
}
|
||||
|
||||
// Create a session in the user's brand context. The session insert
|
||||
// and the last_used_at bump are independent writes — fire them in
|
||||
// parallel and wait on both before returning.
|
||||
const [sessionId, cookieStore] = await Promise.all([
|
||||
withBrand(match.brandId, async (db) => {
|
||||
// and the last_used_at bump run *sequentially* on the same pg
|
||||
// connection inside the `withBrand` transaction — never concurrently.
|
||||
//
|
||||
// The previous shape used a nested `Promise.all([insert, update])`
|
||||
// *and* an outer `Promise.all([withBrand, cookies()])`. That was
|
||||
// known-fragile per the codebase's own commit `ca79896`: "cookies()
|
||||
// corrupts Next.js's request-scoped AsyncLocalStorage if called from
|
||||
// inside a pg transaction." The admin portal has been on the
|
||||
// sequential shape (see `src/lib/water-admin-pin-auth.ts`) since
|
||||
// that commit; this brings the field portal in line.
|
||||
const sessionId = await withBrand(match.brandId, async (db) => {
|
||||
const expiresAt = new Date(
|
||||
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const [insertResult] = await Promise.all([
|
||||
db
|
||||
const [inserted] = await db
|
||||
.insert(waterSessions)
|
||||
.values({
|
||||
irrigatorId: match.id,
|
||||
fieldWorkerId: match.id,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterSessions.id }),
|
||||
db
|
||||
.update(waterIrrigators)
|
||||
.returning({ id: waterSessions.id });
|
||||
if (!inserted) throw new Error("Failed to create session");
|
||||
|
||||
await db
|
||||
.update(fieldWorkers)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(waterIrrigators.id, match.id)),
|
||||
]);
|
||||
const row = insertResult[0];
|
||||
if (!row) throw new Error("Failed to create session");
|
||||
return row.id;
|
||||
}),
|
||||
cookies(),
|
||||
]);
|
||||
cookieStore.set("wl_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: FIELD_SESSION_HOURS * 3600,
|
||||
path: "/",
|
||||
.where(eq(fieldWorkers.id, match.id));
|
||||
|
||||
return inserted.id;
|
||||
});
|
||||
|
||||
// Cookie set is fully outside the DB transaction. Same rule as
|
||||
// `verifyAdminPin` (see comment in `src/lib/water-admin-pin-auth.ts`).
|
||||
await setSessionCookie(WL_SESSION_COOKIE, sessionId, {
|
||||
hours: FIELD_SESSION_HOURS,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_id: match.id,
|
||||
name: match.name,
|
||||
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
role: match.role,
|
||||
session_id: sessionId,
|
||||
lang: match.languagePreference,
|
||||
};
|
||||
@@ -235,7 +299,7 @@ export async function submitWaterEntry(
|
||||
.values({
|
||||
brandId: session.brandId,
|
||||
headgateId,
|
||||
irrigatorId: session.userId,
|
||||
fieldWorkerId: session.userId,
|
||||
measurement: measurement.toString(),
|
||||
unit,
|
||||
totalGallons: totalGallons?.toString() ?? null,
|
||||
@@ -279,6 +343,10 @@ export async function submitWaterEntry(
|
||||
});
|
||||
}
|
||||
|
||||
// Smartsheet sync hook — enqueue + (if realtime) push. Failures
|
||||
// never bubble: the field worker must not see a sync hiccup.
|
||||
void triggerSyncForEntry(session.brandId, entry.id);
|
||||
|
||||
return { success: true, entry_id: entry.id };
|
||||
});
|
||||
}
|
||||
@@ -286,8 +354,7 @@ export async function submitWaterEntry(
|
||||
// ── Cookie/language helpers ───────────────────────────────────────────────
|
||||
|
||||
export async function logoutWater(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
const sessionId = await readSessionCookie(WL_SESSION_COOKIE);
|
||||
if (sessionId) {
|
||||
// Best-effort DB cleanup
|
||||
try {
|
||||
@@ -298,24 +365,15 @@ export async function logoutWater(): Promise<void> {
|
||||
// ignore — the cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_session");
|
||||
await clearSessionCookie(WL_SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function logoutWaterAdmin(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (sessionId) {
|
||||
try {
|
||||
await withPlatformAdmin(async (db) => {
|
||||
await db
|
||||
.delete(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId));
|
||||
});
|
||||
} catch {
|
||||
// ignore — cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_admin_session");
|
||||
// The `wl_admin_session` cookie is now self-contained — see
|
||||
// `src/lib/water-admin-pin-auth.ts`. No DB row to clean up; just
|
||||
// delete the cookie.
|
||||
const { clearAdminSession } = await import("@/lib/water-admin-pin-auth");
|
||||
await clearAdminSession();
|
||||
}
|
||||
|
||||
export async function setWaterLang(lang: string): Promise<void> {
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
*
|
||||
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
|
||||
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
||||
* `water_admin_settings` (one row per brand). Sessions are tracked in
|
||||
* `water_admin_sessions`.
|
||||
* `water_admin_settings` (one row per brand).
|
||||
*
|
||||
* There is currently no per-admin-user PIN — a single brand-wide admin
|
||||
* PIN. If you need per-user PINs (e.g. for an audit trail of which
|
||||
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
|
||||
* key sessions by `admin_user_id`.
|
||||
* PIN verification lives in `src/lib/water-admin-pin-auth.ts` (no
|
||||
* server actions, no Neon Auth). This file only handles the
|
||||
* platform-admin-gated `/admin/water-log/settings` UI which sets /
|
||||
* updates the PIN and other admin settings.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
@@ -22,13 +20,9 @@ import {
|
||||
type WaterAdminSettings,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||
import { hashPin, generatePin } from "@/lib/water-log-pin";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
|
||||
// Note: we deliberately do NOT import `getSession` from `@/lib/auth`.
|
||||
// The water-admin PIN entry flow (`verifyWaterAdminPin`) is PIN-based
|
||||
// and self-contained — see docs/superpowers/specs/2026-07-01-water-log-no-platform-login-design.md.
|
||||
|
||||
export type AdminSettings = {
|
||||
enabled: boolean;
|
||||
sessionDurationHours: number;
|
||||
@@ -192,75 +186,3 @@ const adminUser = await getAdminUser();
|
||||
return { success: true, pin };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PIN verify for the `/water/admin` portal. Creates a session row and
|
||||
* sets the `wl_admin_session` cookie.
|
||||
*/
|
||||
export async function verifyWaterAdminPin(
|
||||
brandId: string,
|
||||
pin: string,
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { success: false, error: formatError };
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterAdminSettings)
|
||||
.where(eq(waterAdminSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const settings = rows[0];
|
||||
if (!settings) {
|
||||
return { success: false, error: "Admin portal not configured" };
|
||||
}
|
||||
if (!settings.enabled) {
|
||||
return { success: false, error: "Admin portal is disabled" };
|
||||
}
|
||||
if (!settings.pinHash) {
|
||||
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
|
||||
}
|
||||
if (!verifyPin(pin, settings.pinHash)) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
// Tie the session to the calling site admin (best-effort).
|
||||
// The `wl_admin_session` is valid whether or not a platform admin
|
||||
// is signed in — this call only attaches an `adminUserId` for
|
||||
// audit. If `getAdminUser()` fails or returns null, we fall back
|
||||
// to a zero-UUID placeholder.
|
||||
let adminUser: Awaited<ReturnType<typeof getAdminUser>> = null;
|
||||
try {
|
||||
adminUser = await getAdminUser();
|
||||
} catch {
|
||||
// ignore — the session is valid regardless
|
||||
}
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||
);
|
||||
const [session] = await db
|
||||
.insert(waterAdminSessions)
|
||||
.values({
|
||||
brandId,
|
||||
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||
pinHashUsed: settings.pinHash,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterAdminSessions.id });
|
||||
if (!session) return { success: false, error: "Failed to create session" };
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_admin_session", session.id, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: settings.sessionDurationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { success: true, session_id: session.id };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* platform_admin) can mutate config; everyone authenticated can read.
|
||||
*
|
||||
* 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 (not the
|
||||
* per-feature config).
|
||||
*/
|
||||
import { desc, eq, and, gte, inArray } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
waterLogEntries,
|
||||
waterSmartsheetConfig,
|
||||
waterSmartsheetSyncLog,
|
||||
waterSmartsheetSyncQueue,
|
||||
SMARTSHEET_FREQUENCIES,
|
||||
SMARTSHEET_COLUMN_KEYS,
|
||||
type SmartsheetColumnKey,
|
||||
type SmartsheetColumnMapping,
|
||||
type SmartsheetFrequency,
|
||||
} from "@/db/schema/water-log";
|
||||
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";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
// ── Types used by both server and client ────────────────────────────────────
|
||||
|
||||
export type SmartsheetConfigResponse = {
|
||||
configured: boolean;
|
||||
sheetId: string | null;
|
||||
syncEnabled: boolean;
|
||||
syncFrequency: SmartsheetFrequency;
|
||||
columnMapping: SmartsheetColumnMapping | null;
|
||||
maskedToken: string | null; // "••••abcd" or null if no token saved
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
hasToken: boolean;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type SmartsheetSyncLogEntry = {
|
||||
id: string;
|
||||
entryId: string | null;
|
||||
smartsheetRowId: string | null;
|
||||
action: "sync" | "retry" | "skip" | "queue";
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type SaveSmartsheetConfigInput = {
|
||||
sheetId: 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;
|
||||
};
|
||||
|
||||
export type TestConnectionResult =
|
||||
| {
|
||||
success: true;
|
||||
sheetName: string;
|
||||
columnCount: number;
|
||||
columns: { id: string; title: string; type: string }[];
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireWaterAdminPermission() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return { ok: false as const, error: "Not authorized" };
|
||||
}
|
||||
return { ok: true as const, adminUser };
|
||||
}
|
||||
|
||||
// ── Read ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSmartsheetConfig(
|
||||
brandId: string,
|
||||
): Promise<SmartsheetConfigResponse> {
|
||||
await getSession();
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterSmartsheetConfig)
|
||||
.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,
|
||||
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,
|
||||
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
|
||||
lastSyncError: config.lastSyncError,
|
||||
hasToken: workspace.hasToken,
|
||||
updatedAt: config.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSmartsheetSyncLog(
|
||||
brandId: string,
|
||||
limit: number = 20,
|
||||
): Promise<SmartsheetSyncLogEntry[]> {
|
||||
await getSession();
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterSmartsheetSyncLog)
|
||||
.where(eq(waterSmartsheetSyncLog.brandId, brandId))
|
||||
.orderBy(desc(waterSmartsheetSyncLog.createdAt))
|
||||
.limit(safeLimit);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
entryId: r.entryId,
|
||||
smartsheetRowId: r.smartsheetRowId,
|
||||
action: r.action,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
durationMs: r.durationMs,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveSmartsheetConfig(
|
||||
brandId: string,
|
||||
input: SaveSmartsheetConfigInput,
|
||||
): Promise<{ success: boolean; error?: string; config?: SmartsheetConfigResponse }> {
|
||||
await getSession();
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
|
||||
// ── Validation ──
|
||||
if (!input.sheetId || typeof input.sheetId !== "string") {
|
||||
return { success: false, error: "Sheet ID or URL is required" };
|
||||
}
|
||||
let normalizedSheetId: string;
|
||||
try {
|
||||
normalizedSheetId = extractSheetId(input.sheetId);
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
if (!SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)) {
|
||||
return { success: false, error: "Invalid sync frequency" };
|
||||
}
|
||||
const mappingResult = validateColumnMapping(input.columnMapping);
|
||||
if (!mappingResult.ok) {
|
||||
return { success: false, error: mappingResult.error };
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
|
||||
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
|
||||
sheetId: normalizedSheetId,
|
||||
syncEnabled: input.syncEnabled,
|
||||
syncFrequency: input.syncFrequency,
|
||||
columnMapping: input.columnMapping,
|
||||
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(waterSmartsheetConfig)
|
||||
.set(updateValues)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId));
|
||||
} else {
|
||||
await db.insert(waterSmartsheetConfig).values({
|
||||
brandId,
|
||||
sheetId: normalizedSheetId,
|
||||
columnMapping: input.columnMapping,
|
||||
syncEnabled: input.syncEnabled,
|
||||
syncFrequency: input.syncFrequency,
|
||||
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: auth.adminUser.user_id ?? null,
|
||||
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||
action: existing ? "update" : "create",
|
||||
entityType: "settings",
|
||||
details: {
|
||||
feature: "smartsheet",
|
||||
sheetId: normalizedSheetId,
|
||||
syncEnabled: input.syncEnabled,
|
||||
syncFrequency: input.syncFrequency,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: await getSmartsheetConfig(brandId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSmartsheetConfig(
|
||||
brandId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
await getSession();
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
return withBrand(brandId, async (db) => {
|
||||
const deleted = await db
|
||||
.delete(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.returning({ id: waterSmartsheetConfig.brandId });
|
||||
if (!deleted[0]) {
|
||||
return { success: false, error: "No config to delete" };
|
||||
}
|
||||
// Also clear the queue + log to avoid orphaned rows.
|
||||
await db
|
||||
.delete(waterSmartsheetSyncQueue)
|
||||
.where(eq(waterSmartsheetSyncQueue.brandId, brandId));
|
||||
// Keep the log for audit, but null the brandId? Simpler: keep
|
||||
// them — they're useful for post-mortems. Foreign keys cascade on
|
||||
// brand delete, but we just unset sync so the log remains a
|
||||
// historical record. (We DON'T delete water_smartsheet_sync_log —
|
||||
// admin may want history.)
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: auth.adminUser.user_id ?? null,
|
||||
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||
action: "delete",
|
||||
entityType: "settings",
|
||||
details: { feature: "smartsheet" },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the Smartsheet connection against a sheet ID, using the
|
||||
* workspace's saved token.
|
||||
*
|
||||
* - 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.
|
||||
*/
|
||||
export async function testSmartsheetConnection(
|
||||
brandId: string,
|
||||
sheetIdInput: string,
|
||||
tokenInput: string,
|
||||
): Promise<TestConnectionResult> {
|
||||
await getSession();
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) return { success: false, error: auth.error };
|
||||
|
||||
if (!sheetIdInput) return { success: false, error: "Sheet ID or URL is required" };
|
||||
let sheetId: string;
|
||||
try {
|
||||
sheetId = extractSheetId(sheetIdInput);
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
|
||||
// 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 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." };
|
||||
}
|
||||
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 {
|
||||
const meta = await getSheetMeta(sheetId, token);
|
||||
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) {
|
||||
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: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called from the entry-insert path.
|
||||
*
|
||||
* Inserts a `pending` queue row. If the brand's frequency is
|
||||
* `realtime`, fires `syncEntryToSmartsheet` immediately (best-effort
|
||||
* — failures don't bubble).
|
||||
*
|
||||
* Safe to call repeatedly — the queue has a UNIQUE(brand_id, entry_id)
|
||||
* constraint so duplicate calls are absorbed.
|
||||
*/
|
||||
export async function triggerSyncForEntry(
|
||||
brandId: string,
|
||||
entryId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const isRealtime = await withBrand(brandId, async (db) => {
|
||||
// Enqueue (idempotent on conflict).
|
||||
await db
|
||||
.insert(waterSmartsheetSyncQueue)
|
||||
.values({ brandId, entryId, status: "pending" })
|
||||
.onConflictDoNothing({
|
||||
target: [waterSmartsheetSyncQueue.brandId, waterSmartsheetSyncQueue.entryId],
|
||||
});
|
||||
// Check config for realtime gating.
|
||||
const rows = await db
|
||||
.select({
|
||||
syncEnabled: waterSmartsheetConfig.syncEnabled,
|
||||
syncFrequency: waterSmartsheetConfig.syncFrequency,
|
||||
})
|
||||
.from(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.limit(1);
|
||||
const cfg = rows[0];
|
||||
return cfg?.syncEnabled && cfg.syncFrequency === "realtime";
|
||||
});
|
||||
|
||||
if (isRealtime) {
|
||||
// Fire-and-forget; never bubble errors to the caller.
|
||||
syncEntryToSmartsheet(brandId, entryId).catch((err) => {
|
||||
console.error("[smartsheet] realtime sync threw", err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Sync setup failures must NEVER block the field-worker submission.
|
||||
console.error("[smartsheet] triggerSyncForEntry failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Backfill: enqueue every existing water log entry for the brand so it
|
||||
* gets pushed to Smartsheet.
|
||||
*
|
||||
* Use cases:
|
||||
* - Customer configured Smartsheet after months/years of recording
|
||||
* entries → they need historical data in the sheet.
|
||||
* - Customer rotated their Smartsheet sheet (different ID) → they
|
||||
* want a clean re-push without changing the per-entry hook.
|
||||
*
|
||||
* Safety:
|
||||
* - Insert is `ON CONFLICT DO NOTHING` so re-runs are no-ops on
|
||||
* already-queued entries. The UNIQUE(brand_id, entry_id) on the
|
||||
* queue absorbs duplicates.
|
||||
* - The sync engine dedups on the Smartsheet side by Entry ID
|
||||
* (`findRowByColumn`), so even if we re-enqueue an old entry whose
|
||||
* queue row was deleted, the sheet still won't gain duplicates.
|
||||
*
|
||||
* Performance / scope:
|
||||
* - Inserts are chunked at 500 rows per round-trip.
|
||||
* - After enqueueing, we kick off one inline drain pass
|
||||
* (batchSize 200) so the admin gets immediate feedback. Any
|
||||
* remaining rows are drained by the cron over time.
|
||||
*/
|
||||
export type EnqueueBackfillOptions = {
|
||||
/** Only enqueue entries logged at-or-after this date. */
|
||||
since?: Date;
|
||||
};
|
||||
|
||||
export type EnqueueBackfillResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Total entries in `water_log_entries` for the brand matching the filter. */
|
||||
considered: number;
|
||||
/** Newly inserted rows in `water_smartsheet_sync_queue`. */
|
||||
newlyQueued: number;
|
||||
/** Entries that were already in the queue (UNIQUE absorbed). */
|
||||
alreadyQueued: number;
|
||||
/**
|
||||
* Result of the inline drain pass that was kicked off after enqueue.
|
||||
* `remaining` is the count of pending/failed queue rows that the
|
||||
* next cron tick will pick up.
|
||||
*/
|
||||
drained: {
|
||||
synced: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
remaining: number;
|
||||
};
|
||||
};
|
||||
|
||||
const BACKFILL_INSERT_CHUNK = 500;
|
||||
const BACKFILL_DRAIN_BATCH = 200;
|
||||
|
||||
export async function enqueueSmartsheetBackfill(
|
||||
brandId: string,
|
||||
opts: EnqueueBackfillOptions = {},
|
||||
): Promise<EnqueueBackfillResult> {
|
||||
await getSession();
|
||||
const auth = await requireWaterAdminPermission();
|
||||
if (!auth.ok) {
|
||||
const empty = {
|
||||
considered: 0,
|
||||
newlyQueued: 0,
|
||||
alreadyQueued: 0,
|
||||
drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 },
|
||||
};
|
||||
return { success: false, error: auth.error, ...empty };
|
||||
}
|
||||
|
||||
// ── Phase 1: validate + insert queue rows (own transaction) ──
|
||||
// IMPORTANT: the drain (phase 2) MUST run in a separate transaction
|
||||
// so it can see the inserts we just committed. The previous
|
||||
// implementation did both inside one withBrand callback, which
|
||||
// meant the drain's MVCC snapshot was taken before the inserts
|
||||
// committed — so it always reported 0/0/0 even with 25+ fresh
|
||||
// rows in the queue.
|
||||
const phase1 = await withBrand(brandId, async (db) => {
|
||||
const configRows = await db
|
||||
.select({
|
||||
syncEnabled: waterSmartsheetConfig.syncEnabled,
|
||||
})
|
||||
.from(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||
.limit(1);
|
||||
const config = configRows[0];
|
||||
if (!config) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error:
|
||||
"Configure Smartsheet first (save your API token and column mapping).",
|
||||
};
|
||||
}
|
||||
if (!config.syncEnabled) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error:
|
||||
"Smartsheet sync is disabled — enable it in the toggle above before backfilling.",
|
||||
};
|
||||
}
|
||||
|
||||
const entryWhere = opts.since
|
||||
? and(
|
||||
eq(waterLogEntries.brandId, brandId),
|
||||
gte(waterLogEntries.loggedAt, opts.since),
|
||||
)
|
||||
: eq(waterLogEntries.brandId, brandId);
|
||||
const entryRows = await db
|
||||
.select({ id: waterLogEntries.id })
|
||||
.from(waterLogEntries)
|
||||
.where(entryWhere);
|
||||
const entryIds = entryRows.map((r) => r.id);
|
||||
if (entryIds.length === 0) {
|
||||
return {
|
||||
ok: true as const,
|
||||
considered: 0,
|
||||
newlyQueued: 0,
|
||||
alreadyQueued: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRows = await db
|
||||
.select({ entryId: waterSmartsheetSyncQueue.entryId })
|
||||
.from(waterSmartsheetSyncQueue)
|
||||
.where(eq(waterSmartsheetSyncQueue.brandId, brandId));
|
||||
const existingSet = new Set(existingRows.map((r) => r.entryId));
|
||||
const toInsert = entryIds.filter((id) => !existingSet.has(id));
|
||||
const alreadyQueued = entryIds.length - toInsert.length;
|
||||
|
||||
let newlyQueued = 0;
|
||||
for (let i = 0; i < toInsert.length; i += BACKFILL_INSERT_CHUNK) {
|
||||
const chunk = toInsert.slice(i, i + BACKFILL_INSERT_CHUNK);
|
||||
const values = chunk.map((entryId) => ({
|
||||
brandId,
|
||||
entryId,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
const inserted = await db
|
||||
.insert(waterSmartsheetSyncQueue)
|
||||
.values(values)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
waterSmartsheetSyncQueue.brandId,
|
||||
waterSmartsheetSyncQueue.entryId,
|
||||
],
|
||||
})
|
||||
.returning({ entryId: waterSmartsheetSyncQueue.entryId });
|
||||
newlyQueued += inserted.length;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
considered: entryIds.length,
|
||||
newlyQueued,
|
||||
alreadyQueued,
|
||||
};
|
||||
});
|
||||
|
||||
if (!phase1.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: phase1.error,
|
||||
considered: 0,
|
||||
newlyQueued: 0,
|
||||
alreadyQueued: 0,
|
||||
drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 2: inline drain in a FRESH transaction (sees the inserts) ──
|
||||
let drained: EnqueueBackfillResult["drained"] = {
|
||||
synced: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
remaining: 0,
|
||||
};
|
||||
try {
|
||||
const result = await drainSyncQueue(brandId, {
|
||||
batchSize: BACKFILL_DRAIN_BATCH,
|
||||
});
|
||||
const remainingRows = await withBrand(brandId, async (db) => {
|
||||
return db
|
||||
.select({ id: waterSmartsheetSyncQueue.id })
|
||||
.from(waterSmartsheetSyncQueue)
|
||||
.where(
|
||||
and(
|
||||
eq(waterSmartsheetSyncQueue.brandId, brandId),
|
||||
inArray(waterSmartsheetSyncQueue.status, ["pending", "failed"]),
|
||||
),
|
||||
);
|
||||
});
|
||||
drained = {
|
||||
synced: result.synced,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
remaining: remainingRows.length,
|
||||
};
|
||||
} catch (err) {
|
||||
// Don't fail the whole backfill if the inline drain errors —
|
||||
// the cron will retry. Just surface to the result.
|
||||
drained = {
|
||||
synced: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
remaining: -1,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...({ error: (err as Error).message } as any),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 3: audit log (logAuditEvent opens its own withBrand) ──
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: auth.adminUser.user_id ?? null,
|
||||
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||
action: "create",
|
||||
entityType: "settings",
|
||||
details: {
|
||||
feature: "smartsheet",
|
||||
event: "backfill",
|
||||
considered: phase1.considered,
|
||||
newlyQueued: phase1.newlyQueued,
|
||||
alreadyQueued: phase1.alreadyQueued,
|
||||
drainedSynced: drained.synced,
|
||||
drainedSkipped: drained.skipped,
|
||||
drainedFailed: drained.failed,
|
||||
since: opts.since?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
considered: phase1.considered,
|
||||
newlyQueued: phase1.newlyQueued,
|
||||
alreadyQueued: phase1.alreadyQueued,
|
||||
drained,
|
||||
};
|
||||
}
|
||||
|
||||
function validateColumnMapping(
|
||||
m: unknown,
|
||||
): { ok: true; value: SmartsheetColumnMapping } | { ok: false; error: string } {
|
||||
if (!m || typeof m !== "object") {
|
||||
return { ok: false, error: "Column mapping is required" };
|
||||
}
|
||||
const obj = m as Record<string, unknown>;
|
||||
const required: SmartsheetColumnKey[] = ["entry_id", "logged_at"];
|
||||
for (const key of required) {
|
||||
if (typeof obj[key] !== "string" || (obj[key] as string).length === 0) {
|
||||
return { ok: false, error: `Column mapping: "${key}" is required` };
|
||||
}
|
||||
}
|
||||
// Optional keys must be either string (with content) or null.
|
||||
for (const key of SMARTSHEET_COLUMN_KEYS) {
|
||||
if (required.includes(key)) continue;
|
||||
const v = obj[key];
|
||||
if (v !== null && v !== undefined && typeof v !== "string") {
|
||||
return { ok: false, error: `Column mapping: "${key}" must be a string or null` };
|
||||
}
|
||||
if (typeof v === "string" && v.length === 0) {
|
||||
// Treat empty string as "not mapped".
|
||||
obj[key] = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
entry_id: obj.entry_id as string,
|
||||
logged_at: obj.logged_at as string,
|
||||
headgate: (obj.headgate as string | null) ?? null,
|
||||
measurement: (obj.measurement as string | null) ?? null,
|
||||
unit: (obj.unit as string | null) ?? null,
|
||||
irrigator: (obj.irrigator as string | null) ?? null,
|
||||
notes: (obj.notes as string | null) ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<CommandPalette />
|
||||
<div
|
||||
id="page-content"
|
||||
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||
className="min-h-screen lg:pl-60 pt-16 lg:pt-0 admin-section outline-none"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* /admin/time-tracking/approvals
|
||||
*
|
||||
* Phase 2 — supervisor / admin queue of submitted timesheets. One-click
|
||||
* approve / reject. Mirrors the "needs review" pattern from the
|
||||
* orders and stops modules so reviewers don't context-switch.
|
||||
*/
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { listTimesheets } from "@/actions/time-tracking/timesheets";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ApprovalsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const { rows } = await listTimesheets(brandId, {
|
||||
status: "submitted",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
title="Approval queue"
|
||||
subtitle={`${rows.length} timesheet${rows.length === 1 ? "" : "s"} pending review`}
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-12 text-center text-sm text-zinc-500">
|
||||
🎉 Nothing to review right now.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
href={`/admin/time-tracking/timesheets/${r.id}`}
|
||||
className="flex items-center justify-between gap-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 hover:bg-amber-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{r.worker_name}
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
({r.worker_role})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600">
|
||||
{r.period_start} → {r.period_end}
|
||||
{r.submitted_notes && (
|
||||
<span className="ml-2 italic">“{r.submitted_notes}”</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-medium tabular-nums text-zinc-900">
|
||||
{Math.floor(r.total_minutes / 60)}h {r.total_minutes % 60}m
|
||||
</div>
|
||||
{r.overtime_minutes > 0 && (
|
||||
<div className="text-xs text-amber-700">
|
||||
+ {Math.floor(r.overtime_minutes / 60)}h {r.overtime_minutes % 60}m OT
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-zinc-500">
|
||||
Submitted{" "}
|
||||
{r.submitted_at
|
||||
? new Date(r.submitted_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* /admin/time-tracking/audit
|
||||
*
|
||||
* Phase 2 — read-only audit trail viewer for admins. Shows every
|
||||
* mutating action on a timesheet, its entries, or the related
|
||||
* entities, ordered most-recent-first.
|
||||
*/
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { timeTrackingAuditLog } from "@/db/schema/time-tracking";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuditPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ action?: string; entity?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (adminUser.role !== "platform_admin" && adminUser.role !== "brand_admin") {
|
||||
redirect("/admin/time-tracking");
|
||||
}
|
||||
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
const sp = await searchParams;
|
||||
|
||||
const rows = await withBrand(brandId, async (db) => {
|
||||
const conds = [eq(timeTrackingAuditLog.brandId, brandId)];
|
||||
if (sp.action) conds.push(eq(timeTrackingAuditLog.action, sp.action));
|
||||
if (sp.entity) conds.push(eq(timeTrackingAuditLog.entityType, sp.entity));
|
||||
return db
|
||||
.select({
|
||||
id: timeTrackingAuditLog.id,
|
||||
actor_label: timeTrackingAuditLog.actorLabel,
|
||||
actor_kind: timeTrackingAuditLog.actorKind,
|
||||
action: timeTrackingAuditLog.action,
|
||||
entity_type: timeTrackingAuditLog.entityType,
|
||||
entity_id: timeTrackingAuditLog.entityId,
|
||||
details: timeTrackingAuditLog.details,
|
||||
created_at: timeTrackingAuditLog.createdAt,
|
||||
})
|
||||
.from(timeTrackingAuditLog)
|
||||
.where(sql`1=1`)
|
||||
.orderBy(desc(timeTrackingAuditLog.createdAt))
|
||||
.limit(500);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
title="Audit trail"
|
||||
subtitle={`Last ${rows.length} events`}
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
<form className="mb-4 flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Action
|
||||
</span>
|
||||
<input
|
||||
name="action"
|
||||
defaultValue={sp.action ?? ""}
|
||||
placeholder="e.g. timesheet.approve"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Entity type
|
||||
</span>
|
||||
<select
|
||||
name="entity"
|
||||
defaultValue={sp.entity ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="timesheet">timesheet</option>
|
||||
<option value="log">log</option>
|
||||
<option value="worker">worker</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">When</th>
|
||||
<th className="px-4 py-3">Action</th>
|
||||
<th className="px-4 py-3">Actor</th>
|
||||
<th className="px-4 py-3">Entity</th>
|
||||
<th className="px-4 py-3">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-zinc-500">
|
||||
{new Date(r.created_at).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-700">
|
||||
{r.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-900">
|
||||
{r.actor_label}
|
||||
<span className="ml-1 text-xs text-zinc-500">
|
||||
({r.actor_kind})
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-500">
|
||||
{r.entity_type}
|
||||
{r.entity_id && (
|
||||
<span className="ml-1 font-mono">{r.entity_id.slice(0, 8)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<pre className="max-w-md overflow-x-auto rounded bg-zinc-50 px-2 py-1 text-[11px] text-zinc-600">
|
||||
{JSON.stringify(r.details, null, 0)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||
import { FleetRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminTimeTrackingPage() {
|
||||
const TABS = [
|
||||
{ href: "/admin/time-tracking", label: "Overview" },
|
||||
{ href: "/admin/time-tracking/timesheets", label: "Timesheets" },
|
||||
{ href: "/admin/time-tracking/approvals", label: "Approvals" },
|
||||
{ href: "/admin/time-tracking/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
export default async function AdminTimeTrackingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
if (!adminUser) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const isPlatformAdmin = adminUser?.role === "platform_admin";
|
||||
const isTuxedoAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID;
|
||||
const isIRDAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === IRD_BRAND_ID;
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const isTuxedoAdmin =
|
||||
adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
|
||||
const isIRDAdmin =
|
||||
adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
|
||||
|
||||
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const effectiveBrandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
const sp = await searchParams;
|
||||
const activeTab = sp.tab ?? "overview";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
title="Time Tracking"
|
||||
subtitle="Manage workers, tasks, and time logs"
|
||||
subtitle="Manage workers, tasks, timesheets, and approvals"
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
{/* Tab nav */}
|
||||
<nav className="mb-6 flex gap-1 rounded-xl border border-zinc-200 bg-white p-1">
|
||||
{TABS.map((t) => {
|
||||
const active =
|
||||
t.href === "/admin/time-tracking"
|
||||
? activeTab === "overview" || t.href === `/admin/time-tracking${activeTab === "overview" ? "" : ""}`
|
||||
: activeTab && t.href.endsWith(activeTab);
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-zinc-900 text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
||||
|
||||
<div className="mt-6">
|
||||
<FleetRouteSummaryClient brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function FleetRouteSummaryClient({ brandId }: { brandId: string }) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const rows = await getFleetRouteSummary(brandId, today);
|
||||
return <FleetRouteSummary brandId={brandId} rows={rows} date={today} />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* /admin/time-tracking/timesheets/[id]
|
||||
*
|
||||
* Phase 2 — single timesheet view. Shows the period totals, every
|
||||
* entry (clock-in / manual), GPS pin status, audit trail, and the
|
||||
* approval/lock controls.
|
||||
*
|
||||
* Workflow buttons rendered conditionally on the caller's role:
|
||||
* - submit: worker / supervisor when status = draft | rejected
|
||||
* - approve: supervisor / admin when status = submitted
|
||||
* - reject: supervisor / admin when status = submitted
|
||||
* - unlock: admin only, when status = approved
|
||||
* - manual entry: admin / supervisor, when status != approved
|
||||
*/
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import {
|
||||
getTimesheet,
|
||||
getCurrentPayPeriod,
|
||||
ensureTimesheetForPeriod,
|
||||
} from "@/actions/time-tracking/timesheets";
|
||||
import { getTimeTrackingWorkers } from "@/actions/time-tracking";
|
||||
import TimesheetDetail from "@/components/time-tracking/admin/TimesheetDetail";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TimesheetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
const { id } = await params;
|
||||
|
||||
// "current" pseudo-id — fetch or create the current pay period for the
|
||||
// admin's user. Useful as a shortcut from the dashboard.
|
||||
if (id === "current") {
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
if (!brandId) redirect("/admin/time-tracking/timesheets");
|
||||
const period = await getCurrentPayPeriod(brandId);
|
||||
const me = adminUser.email;
|
||||
const workers = await getTimeTrackingWorkers(brandId).catch(() => []);
|
||||
const mine = workers.find((w) => w.name.toLowerCase() === (me ?? "").toLowerCase());
|
||||
if (!mine) redirect("/admin/time-tracking/timesheets");
|
||||
const tid = await ensureTimesheetForPeriod(brandId, mine.id, period.start, period.end);
|
||||
redirect(`/admin/time-tracking/timesheets/${tid}`);
|
||||
}
|
||||
|
||||
const data = await getTimesheet(id);
|
||||
if (!data) notFound();
|
||||
|
||||
const isAdmin =
|
||||
adminUser.role === "platform_admin" || adminUser.role === "brand_admin";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
title={`Timesheet — ${data.summary.worker_name}`}
|
||||
subtitle={`${data.summary.period_start} → ${data.summary.period_end}`}
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
<TimesheetDetail
|
||||
summary={data.summary}
|
||||
entries={data.entries}
|
||||
audit={data.audit}
|
||||
caller={{
|
||||
id: adminUser.id,
|
||||
role: adminUser.role,
|
||||
display_name: adminUser.display_name,
|
||||
email: adminUser.email,
|
||||
isAdmin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* /admin/time-tracking/timesheets
|
||||
*
|
||||
* Phase 2 — list of all timesheets for the brand. Filters by status,
|
||||
* worker, period. Admins see everything; supervisors see only their
|
||||
* assigned crew.
|
||||
*/
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { listTimesheets, type TimesheetSummary } from "@/actions/time-tracking/timesheets";
|
||||
import { getTimeTrackingWorkers } from "@/actions/time-tracking";
|
||||
import TimesheetsList from "@/components/time-tracking/admin/TimesheetsList";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TimesheetsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
status?: string;
|
||||
workerId?: string;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
}>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
const sp = await searchParams;
|
||||
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const status =
|
||||
sp.status === "draft" ||
|
||||
sp.status === "submitted" ||
|
||||
sp.status === "approved" ||
|
||||
sp.status === "rejected"
|
||||
? sp.status
|
||||
: "all";
|
||||
|
||||
const { rows, total } = await listTimesheets(brandId, {
|
||||
status,
|
||||
workerId: sp.workerId,
|
||||
periodStart: sp.periodStart,
|
||||
periodEnd: sp.periodEnd,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const workers = await getTimeTrackingWorkers(brandId).catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
title="Timesheets"
|
||||
subtitle={`${total} timesheet${total === 1 ? "" : "s"}`}
|
||||
className="px-6 pt-6"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
<TimesheetsList
|
||||
rows={rows as TimesheetSummary[]}
|
||||
total={total}
|
||||
workers={workers.map((w) => ({ id: w.id, name: w.name, role: w.role }))}
|
||||
filters={{
|
||||
status,
|
||||
workerId: sp.workerId,
|
||||
periodStart: sp.periodStart,
|
||||
periodEnd: sp.periodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterEntryById } from "@/actions/water-log/admin";
|
||||
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
|
||||
import Link from "next/link";
|
||||
@@ -26,7 +26,7 @@ export default async function WaterLogEntryPage({ params, searchParams }: EntryP
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
|
||||
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +27,7 @@ export default async function WaterLogHeadgatePage({ params, searchParams }: Hea
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
|
||||
@@ -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,6 +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";
|
||||
|
||||
@@ -260,13 +263,44 @@ export default function WaterLogSettingsPage() {
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||
role={message.type === "error" ? "alert" : "status"}
|
||||
aria-live={message.type === "error" ? "assertive" : "polite"}
|
||||
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px] font-medium
|
||||
${
|
||||
message.type === "success"
|
||||
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
|
||||
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
|
||||
? "border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.05] text-[#143d24]"
|
||||
: "border-[#a4452b]/25 bg-[#a4452b]/[0.05] text-[#7a341f]"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white ${
|
||||
message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
|
||||
}`}
|
||||
>
|
||||
{message.type === "success" ? (
|
||||
<svg viewBox="0 0 12 12" className="h-3 w-3">
|
||||
<path
|
||||
d="M2 6.5L5 9.5L10 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 12 12" className="h-3 w-3">
|
||||
<path
|
||||
d="M6 3V6.5M6 8.5V9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<p className="leading-snug">{message.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -316,9 +350,22 @@ export default function WaterLogSettingsPage() {
|
||||
type="button"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||
className="inline-flex min-h-[40px] shrink-0 items-center justify-center rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2
|
||||
text-[14px] font-semibold text-white
|
||||
motion-safe:transition-[background-color,transform] motion-safe:duration-150 motion-safe:ease-out
|
||||
hover:bg-[#143d24]
|
||||
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30
|
||||
active:scale-[0.97] active:bg-[#0f2e1c]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{regenerating ? "Generating…" : "Regenerate PIN"}
|
||||
{regenerating ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
|
||||
Generating…
|
||||
</span>
|
||||
) : (
|
||||
"Regenerate PIN"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -392,7 +439,10 @@ export default function WaterLogSettingsPage() {
|
||||
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
|
||||
}
|
||||
placeholder="+13035551234"
|
||||
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||
className="mt-1 block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
|
||||
text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
|
||||
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
|
||||
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#8a8b88]">
|
||||
@@ -407,11 +457,66 @@ export default function WaterLogSettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||
className="inline-flex min-h-[48px] w-full items-center justify-center rounded-lg bg-[#1a4d2e] px-6 py-3
|
||||
text-[14px] font-semibold tracking-tight text-white
|
||||
shadow-[0_1px_0_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.06)]
|
||||
motion-safe:transition-[background-color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
|
||||
hover:bg-[#143d24] hover:shadow-[0_2px_4px_rgba(15,23,18,0.1),inset_0_1px_0_rgba(255,255,255,0.06)]
|
||||
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fdfaf2]
|
||||
active:scale-[0.985] active:bg-[#0f2e1c]
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
{saving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
|
||||
Saving…
|
||||
</span>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 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
|
||||
</p>
|
||||
<h2
|
||||
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Smartsheet
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||
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>
|
||||
);
|
||||
@@ -427,10 +532,16 @@ function Card({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||
<section className="rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)]">
|
||||
<header className="mb-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-[13px] leading-snug text-[#5a5d5a]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -460,14 +571,17 @@ function ToggleRow({
|
||||
aria-checked={value}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!value)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
|
||||
}`}
|
||||
className={`group relative inline-flex h-[26px] w-[46px] shrink-0 items-center rounded-full
|
||||
motion-safe:transition-colors motion-safe:duration-200
|
||||
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25 focus-visible:ring-offset-2 focus-visible:ring-offset-white
|
||||
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out
|
||||
${value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
className={`inline-block h-[20px] w-[20px] transform rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.2),0_1px_1px_rgba(0,0,0,0.06)]
|
||||
motion-safe:transition-transform motion-safe:duration-200
|
||||
motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]
|
||||
${value ? "translate-x-[22px]" : "translate-x-[3px]"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/auth";
|
||||
import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth";
|
||||
import { getWaterIrrigators } from "@/actions/water-log/admin";
|
||||
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
||||
import { DailyRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
|
||||
import { getRouteTimeSummary } from "@/actions/time-tracking/route-summary";
|
||||
import Link from "next/link";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
@@ -27,7 +29,7 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
const isWaterAdmin = waterSession !== null;
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
@@ -117,7 +119,25 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag
|
||||
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouteSummaryCard brandId={TUXEDO_BRAND_ID} workerId={waterUser.id} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function RouteSummaryCard({
|
||||
brandId,
|
||||
workerId,
|
||||
}: {
|
||||
brandId: string;
|
||||
workerId: string;
|
||||
}) {
|
||||
const summary = await getRouteTimeSummary(brandId, workerId);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<DailyRouteSummary summary={summary} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,14 +226,6 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
setTimeout(() => dispatch({ type: "set_msg", msg: null }), 4000);
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<WholesaleLoadingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ value: "dashboard", label: "Dashboard", count: undefined },
|
||||
{ value: "customers", label: "Customers", count: state.customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length },
|
||||
@@ -251,6 +243,13 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
className="mb-0"
|
||||
/>
|
||||
|
||||
{state.loading ? (
|
||||
<div className="px-6 py-6">
|
||||
<WholesaleLoadingSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div className="px-6 pb-0">
|
||||
<AdminFilterTabs
|
||||
@@ -330,6 +329,8 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
/>
|
||||
)}
|
||||
</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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GET /api/time-tracking/timesheets/[id]/export?format=csv|pdf
|
||||
*
|
||||
* Returns the timesheet as a CSV or PDF blob. Same authz as the server
|
||||
* action; the route exists because file downloads from a `<a href>` are
|
||||
* far simpler than marshalling a binary blob through a Server Action
|
||||
* return value.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import {
|
||||
exportTimesheetCsv,
|
||||
exportTimesheetPdf,
|
||||
} from "@/actions/time-tracking/export";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const url = new URL(req.url);
|
||||
const format = url.searchParams.get("format") ?? "csv";
|
||||
|
||||
if (format === "csv") {
|
||||
const result = await exportTimesheetCsv(id);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return new NextResponse(result.csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${result.filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "pdf") {
|
||||
const result = await exportTimesheetPdf(id);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
// Return the PDF as a binary stream. NextResponse accepts a Buffer.
|
||||
return new NextResponse(Buffer.from(result.pdf!), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${result.filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported format '${format}' (use csv or pdf)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
/**
|
||||
* POST /api/water-admin-auth
|
||||
*
|
||||
* Body: { brandId: string, pin: string }
|
||||
* Body: { pin: string }
|
||||
* Side effect: sets the `wl_admin_session` cookie on success.
|
||||
*
|
||||
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
|
||||
* itself is created by `verifyWaterAdminPin()` in
|
||||
* `src/actions/water-log/settings.ts`, which is the single source of
|
||||
* truth for admin PIN verification.
|
||||
* Used by the mobile/PIN-only `/water/admin/login` portal. Brand is
|
||||
* hardcoded (Tuxedo-only — see CLAUDE.md "Public Storefront
|
||||
* Architecture"). All auth logic lives in
|
||||
* `src/lib/water-admin-pin-auth.ts`.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
|
||||
import { verifyAdminPin } from "@/lib/water-admin-pin-auth";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { brandId, pin } = (await request.json()) as {
|
||||
brandId?: string;
|
||||
pin?: string;
|
||||
};
|
||||
if (!brandId || !pin) {
|
||||
const { pin } = (await request.json()) as { pin?: string };
|
||||
if (!pin) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing brandId or pin" },
|
||||
{ success: false, error: "Missing pin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const result = await verifyWaterAdminPin(brandId, pin);
|
||||
if (!result.success) {
|
||||
const result = await verifyAdminPin(pin);
|
||||
if (!result.ok) {
|
||||
// Don't leak whether the PIN was wrong vs. not configured — both
|
||||
// are the same to a field attacker probing the endpoint.
|
||||
const status = result.error === "Invalid PIN" ? 401 : 403;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Cron-driven Smartsheet sync drain.
|
||||
*
|
||||
* Hit by Vercel cron (see vercel.json entry: every 15 minutes).
|
||||
* Drains pending + due-retry queue rows for every brand with
|
||||
* sync_enabled = true (regardless of frequency — for realtime
|
||||
* brands this is purely a backstop in case event-driven pushes
|
||||
* failed; the cron recovers them via the same retry mechanism).
|
||||
*
|
||||
* Auth:
|
||||
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
|
||||
* or `?secret=$CRON_SECRET` query param (Vercel cron supports
|
||||
* neither natively; we use the header from a curl / GitHub
|
||||
* Action / external scheduler). Falls back to `CRON_SECRET` for
|
||||
* cross-compatibility with existing automation routes.
|
||||
*
|
||||
* Body (optional):
|
||||
* - `{ brandId?: string }` — restrict to one brand (useful for
|
||||
* "Retry now" admin buttons). If omitted, runs for every active
|
||||
* brand.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import { waterSmartsheetConfig } from "@/db/schema/water-log";
|
||||
import { drainSyncQueue } from "@/services/smartsheet-sync";
|
||||
|
||||
const CRON_SECRET =
|
||||
process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? "";
|
||||
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
function authenticate(req: NextRequest): boolean {
|
||||
if (!CRON_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 === CRON_SECRET || queryToken === CRON_SECRET;
|
||||
}
|
||||
|
||||
async function listActiveBrandIds(): Promise<string[]> {
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({ id: waterSmartsheetConfig.brandId })
|
||||
.from(waterSmartsheetConfig)
|
||||
.where(eq(waterSmartsheetConfig.syncEnabled, true));
|
||||
return rows.map((r) => r.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!authenticate(req)) return unauthorized();
|
||||
|
||||
let body: { brandId?: string } = {};
|
||||
try {
|
||||
// Body may be empty for the simple cron case.
|
||||
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(async (brandId) => {
|
||||
try {
|
||||
const result = await drainSyncQueue(brandId);
|
||||
return { ...result, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
brandId,
|
||||
considered: 0,
|
||||
synced: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
error: (err as Error).message ?? "Unknown error",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const totals = 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 },
|
||||
);
|
||||
|
||||
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,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||
import {
|
||||
WL_ADMIN_SESSION_COOKIE,
|
||||
WL_SESSION_COOKIE,
|
||||
} from "@/lib/water-log/session";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Validate session — irrigators use wl_session, admins use wl_admin_session
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
|
||||
const sessionId =
|
||||
cookieStore.get(WL_SESSION_COOKIE)?.value ??
|
||||
cookieStore.get(WL_ADMIN_SESSION_COOKIE)?.value;
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -302,6 +302,42 @@ select:-webkit-autofill:focus {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* The water success screen used `water-draw`/`water-ring`; the Time
|
||||
* Tracking Clocked Out screen used inline `strokeDashoffset` transitions
|
||||
* + a single `field-draw`. Cycle 11 collapses everything into shared
|
||||
* `field-*` keyframes so a worker who clocks out sees the same
|
||||
* ring-and-check reveal choreography as a worker who submits a water
|
||||
* reading. */
|
||||
@keyframes field-draw {
|
||||
from { stroke-dashoffset: var(--draw-from, 100); }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes field-ring {
|
||||
from { stroke-dashoffset: var(--ring-from, 360); }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
/* Mobile Field — PIN caret blink. The `to { opacity: 0 }` +
|
||||
* `steps(2, start)` combo gives a hard on/off blink like
|
||||
* UISegmentedControl / UITextField, not a gentle fade. */
|
||||
@keyframes field-blink {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Mobile Field — PIN-entry error shake. Three diminishing oscillations
|
||||
* for a "no, that wasn't right" feel. The shake is applied directly
|
||||
* to the digit-box row in `FieldPinScreen.tsx`, not to an empty
|
||||
* overlay, so the user actually sees the row jerk back and forth. */
|
||||
@keyframes field-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-10px); }
|
||||
40% { transform: translateX(10px); }
|
||||
60% { transform: translateX(-7px); }
|
||||
80% { transform: translateX(7px); }
|
||||
}
|
||||
|
||||
|
||||
/* Reduce motion: respect the user's OS-level preference. */
|
||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||
* but flattens everything that fights vestibular comfort. This block wipes:
|
||||
@@ -1073,6 +1109,32 @@ select:-webkit-autofill:focus {
|
||||
.atelier-backdrop {
|
||||
animation: atelier-backdrop 140ms ease-out both;
|
||||
}
|
||||
|
||||
/* Smartsheet integration polish — Apple HIG-inspired micro-animations.
|
||||
* Kept short (180–220ms) and single-property to feel responsive. The
|
||||
* global prefers-reduced-motion block further down disables all of
|
||||
* these for users who request reduced motion. */
|
||||
@keyframes smartsheet-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes smartsheet-sheet-up {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes smartsheet-slide-down {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.smartsheet-fade-in {
|
||||
animation: smartsheet-fade-in 180ms ease-out both;
|
||||
}
|
||||
.smartsheet-sheet-up {
|
||||
animation: smartsheet-sheet-up 220ms cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
}
|
||||
.smartsheet-slide-down {
|
||||
animation: smartsheet-slide-down 180ms cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
animation: atelier-stagger 180ms ease-out both;
|
||||
/* No more 80-440ms cumulative delays — items appear together, not as a parade. */
|
||||
@@ -1321,3 +1383,107 @@ select:-webkit-autofill:focus {
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ── Tuxedo storefront: Harvest editorial ambient drift ───────────────
|
||||
* Slow Ken Burns on the harvest packshot. Reads as ambient — the photo
|
||||
* is breathing, not animating. Honors prefers-reduced-motion below. */
|
||||
@keyframes harvest-drift {
|
||||
0% { transform: scale(1.04) translate(0, 0); }
|
||||
50% { transform: scale(1.07) translate(-0.5%, -0.3%); }
|
||||
100% { transform: scale(1.10) translate(-1%, -0.6%); }
|
||||
}
|
||||
.animate-harvest-drift {
|
||||
animation: harvest-drift 30s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-harvest-drift {
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tuxedo storefront: editorial photo color grade ────────────────────
|
||||
* Subtle Lightroom-style treatment applied to all hero / pull-quote /
|
||||
* founder / packshot / ear-macro / storm-field photos. The raw WP-import
|
||||
* JPGs skew saturated-yellow and slightly flat — the corn itself reads
|
||||
* garish, the shadows crushed, the highlights cheap. This filter:
|
||||
* - saturate(.86) pulls the corn's yellow down so the photo doesn't
|
||||
* shout louder than the white type.
|
||||
* - contrast(1.06) magazine-print contrast — slightly punchier
|
||||
* without crushing shadows.
|
||||
* - brightness(.97) trims ~3% so the photo sits a half-step below the
|
||||
* type in the brightness hierarchy.
|
||||
* Stacked with a faint cool-tone overlay (see .filter-editorial-shell)
|
||||
* for a split-tone effect: shadows drift toward navy/slate, highlights
|
||||
* stay warm. Reads as a graded magazine photo, not a raw JPG.
|
||||
*/
|
||||
.filter-editorial {
|
||||
filter: saturate(0.86) contrast(1.06) brightness(0.97);
|
||||
}
|
||||
|
||||
/* Wrap an image in a .filter-editorial-shell to also apply the cool
|
||||
* shadow tint. The shell uses a mix-blend-mode overlay so the tint
|
||||
* blends with the photo rather than sitting on top of it.
|
||||
* ::before = slate multiply (cools highlights, lifts shadows)
|
||||
* ::after = amber soft-light (warms midtones back, restores the
|
||||
* corn's color without bringing the saturation back) */
|
||||
.filter-editorial-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.filter-editorial-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 30, 50, 0.16);
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.filter-editorial-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(232, 163, 23, 0.07);
|
||||
mix-blend-mode: soft-light;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.filter-editorial-shell > img {
|
||||
filter: saturate(0.86) contrast(1.06) brightness(0.97);
|
||||
}
|
||||
|
||||
/* Heavier grade for the harvest packshot — it's the most chromatically
|
||||
* dense image on the page (piles of yellow cobs) so it needs an
|
||||
* extra-strong desaturation pull to read as editorial, not product
|
||||
* photography. */
|
||||
.filter-editorial-strong {
|
||||
filter: saturate(0.78) contrast(1.10) brightness(0.95);
|
||||
}
|
||||
|
||||
/* Subtle film vignette used on full-bleed editorial photos (packshot,
|
||||
* storm field, founder). Draws the eye toward the center where the
|
||||
* typography anchors. */
|
||||
.filter-editorial-vignette::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
transparent 35%,
|
||||
rgba(0, 0, 0, 0.18) 80%,
|
||||
rgba(0, 0, 0, 0.38) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-editorial,
|
||||
.filter-editorial-shell > img,
|
||||
.filter-editorial-strong {
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { m as motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
|
||||
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
|
||||
const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimelineSection"));
|
||||
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
|
||||
const CTASection = lazy(() => import("@/components/about/CTASection"));
|
||||
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
interface AboutClientProps {
|
||||
logoUrl: string | null;
|
||||
@@ -28,7 +25,6 @@ interface AboutClientProps {
|
||||
export default function AboutClient({
|
||||
logoUrl,
|
||||
logoUrlDark,
|
||||
olatheSweetLogoUrlDark,
|
||||
aboutHeadline,
|
||||
aboutSubheadline,
|
||||
showWholesaleLink,
|
||||
@@ -51,101 +47,25 @@ export default function AboutClient({
|
||||
/>
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-stone-950 overflow-hidden">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,0.1) 35px, rgba(255,255,255,0.1) 70px)' }} />
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6 py-24 md:py-36">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Text content */}
|
||||
<div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-[11px] font-bold uppercase tracking-[0.3em] text-emerald-400/70 mb-4"
|
||||
>
|
||||
Our Heritage
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.15 }}
|
||||
className="text-5xl md:text-6xl lg:text-7xl font-black text-white tracking-tight leading-[1] mb-6"
|
||||
>
|
||||
{aboutHeadline ?? "Three Generations of Sweet Corn Excellence"}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="text-xl md:text-2xl text-stone-300 leading-relaxed"
|
||||
>
|
||||
{aboutSubheadline ?? "Over 40 years of growing and shipping Olathe Sweet Sweet Corn from our family to yours."}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="mt-8 h-1 w-16 bg-gradient-to-r from-emerald-500 to-amber-500 rounded-full origin-left"
|
||||
<HeroSection
|
||||
headline={aboutHeadline ?? "Three generations of sweet corn excellence."}
|
||||
subheadline={
|
||||
aboutSubheadline ??
|
||||
"Forty summers of the same seed, the same soil, and the same row — grown at five thousand, three hundred and sixty feet, picked by hand, packed the morning of."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex justify-center lg:justify-end"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-emerald-900/30 rounded-full blur-2xl" />
|
||||
<Image
|
||||
src={olatheSweetLogoUrlDark ?? "/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"}
|
||||
alt="Olathe Sweet"
|
||||
width={288}
|
||||
height={288}
|
||||
unoptimized
|
||||
className="relative w-56 md:w-72 h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<TheCornSection />
|
||||
|
||||
{/* Bottom transition */}
|
||||
<div className="absolute -bottom-px left-0 right-0 h-3 bg-stone-50" />
|
||||
</section>
|
||||
<PullQuoteBand />
|
||||
|
||||
{/* Mission Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-50" />}>
|
||||
<MissionSection />
|
||||
</Suspense>
|
||||
<FounderStrip />
|
||||
|
||||
{/* Family Timeline */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-100" />}>
|
||||
<FamilyTimelineSection />
|
||||
</Suspense>
|
||||
<FamilyTimeline />
|
||||
|
||||
{/* Contact Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
|
||||
<ContactSection address={address} />
|
||||
</Suspense>
|
||||
<VisitTheFarm address={address} />
|
||||
|
||||
{/* CTA Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-white" />}>
|
||||
<CTASection />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
@@ -163,6 +83,537 @@ export default function AboutClient({
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback({ bg }: { bg: string }) {
|
||||
return <div className={`py-24 md:py-32 ${bg}`} />;
|
||||
// ── HERO — dark editorial opener ────────────────────────────────────
|
||||
// Same architectural language as the homepage FounderStrip, but with
|
||||
// the portrait as a full anchor instead of a supporting figure, and
|
||||
// the headline sitting in the same Fraunces display register as the
|
||||
// homepage "Three generations of sweet corn excellence" copy.
|
||||
function HeroSection({
|
||||
headline,
|
||||
subheadline,
|
||||
}: {
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative bg-stone-950 overflow-hidden">
|
||||
{/* A single amber rim along the bottom — same hinge used
|
||||
throughout the homepage between cream and dark sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
{/* Copy column — 7/12 on desktop */}
|
||||
<div className="lg:col-span-7 order-2 lg:order-1">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Our Heritage · Est. 1982
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
{headline.split(/(excellence\.)/i).map((part, i) =>
|
||||
/excellence\.$/i.test(part) ? (
|
||||
<span key={i} className="italic text-amber-200/90">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
{subheadline}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">Olathe, Colorado</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">5,360 ft</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Uncompahgre Valley</span>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Portrait column — 5/12 on desktop */}
|
||||
<div className="lg:col-span-5 order-1 lg:order-2">
|
||||
<FadeOnScroll from="right" duration={0.4}>
|
||||
<div className="relative aspect-[4/5] sm:aspect-[5/6] overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)] filter-editorial-shell">
|
||||
<Image
|
||||
src={WP_IMAGES.founderPortrait}
|
||||
alt="John Harold, founder, walking through his corn rows inspecting the tassels"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||
quality={92}
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* Soft amber wash on the bottom — matches the homepage
|
||||
FounderStrip treatment. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 sm:bottom-5 sm:left-5 px-3 py-1.5 rounded-full bg-stone-950/70 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
|
||||
John Harold · Olathe, CO
|
||||
</div>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── THE CORN — split editorial moment ───────────────────────────────
|
||||
// Mirrors the homepage's "The Corn" section: copy left (5/12),
|
||||
// single macro photograph right (7/12). One paragraph. One image.
|
||||
// The feature-card grid read as a templated list of icons; a single
|
||||
// honest photograph communicates everything those tiles couldn't.
|
||||
function TheCornSection() {
|
||||
return (
|
||||
<section
|
||||
id="the-corn"
|
||||
className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden"
|
||||
>
|
||||
<LayoutContainer>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
{/* Left: copy column, 5/12 */}
|
||||
<div className="lg:col-span-5 order-2 lg:order-1">
|
||||
<FadeOnScroll from="left">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
The Corn
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.1}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
You can taste<br className="hidden sm:block" />
|
||||
<span className="italic text-amber-700/90">the altitude.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.2}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.3}>
|
||||
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
|
||||
Olathe Sweet was developed for the Uncompahgre Valley — five
|
||||
thousand, three hundred and sixty feet above the sea, where
|
||||
the daytime sun hits the husk and the nights cool it back
|
||||
down before sunrise. That diurnal swing is what loads every
|
||||
kernel with sugar; nothing we do in the kitchen can
|
||||
replicate it, and nothing in a greenhouse can grow it.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.4}>
|
||||
<p className="mt-5 text-stone-600 leading-[1.65]">
|
||||
The ear in the photograph was picked the morning it was
|
||||
shot. The silk on the right is still damp. We don’t
|
||||
airbrush the field because we don’t need to.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Right: macro photo, 7/12 */}
|
||||
<div className="lg:col-span-7 order-1 lg:order-2">
|
||||
<FadeOnScroll from="right" duration={0.4}>
|
||||
<div className="relative aspect-[5/4] sm:aspect-[16/11] overflow-hidden rounded-2xl shadow-[0_30px_80px_-30px_rgba(15,40,20,0.45)] ring-1 ring-stone-900/5 filter-editorial-shell">
|
||||
<Image
|
||||
src={WP_IMAGES.earMacro}
|
||||
alt="A single ear of Olathe Sweet corn, partially husked, silk still attached, against a field of dark green tassels"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 58vw"
|
||||
quality={90}
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
|
||||
Olathe Sweet · Day of pick
|
||||
</div>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PULL-QUOTE BAND — full-bleed editorial interlude ───────────────
|
||||
// Same composition the homepage uses: pre-storm field behind, single
|
||||
// Fraunces italic quote floating in the lower third. No chrome. The
|
||||
// image does the talking.
|
||||
function PullQuoteBand() {
|
||||
return (
|
||||
<section
|
||||
className="relative isolate overflow-hidden"
|
||||
style={{ minHeight: "min(70vh, 640px)" }}
|
||||
>
|
||||
<Image
|
||||
src={WP_IMAGES.stormField}
|
||||
alt="Pre-storm corn rows receding to the mountains at dusk"
|
||||
fill
|
||||
sizes="100vw"
|
||||
quality={88}
|
||||
className="object-cover -z-10 filter-editorial"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
|
||||
/>
|
||||
|
||||
<div className="relative min-h-[inherit] flex items-end pb-16 sm:pb-24">
|
||||
<div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p
|
||||
className="font-display text-white text-[clamp(1.75rem,3.8vw,3.25rem)] leading-[1.08] tracking-[-0.015em] italic"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
“The field decides the sweetness.<br className="hidden sm:block" />{" "}
|
||||
<span className="text-amber-200/95">We just pick it on time.”</span>
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mt-8 mx-auto h-px w-12 bg-amber-300/70" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.25}>
|
||||
<p className="mt-5 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FOUNDER STRIP — narrative anchor for "The People" section ──────
|
||||
// Cream background, magazine spread. Eyebrow + Fraunces headline on
|
||||
// the left, two paragraphs of founder story on the right. Below it
|
||||
// the FamilyTimeline component renders the four family-member entries
|
||||
// in the same hairline-ruled editorial register.
|
||||
function FounderStrip() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
The People Behind the Corn
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
The Harold family,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">on the same row.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
John Harold began selling Olathe’s sweet corn directly
|
||||
from pickups in the 1970s. The Olathe Sweet parent line was
|
||||
rescued from a retiring farmer who believed his valley could
|
||||
grow sugar in a husk. Three generations later, the name on
|
||||
the cooler is still the name on the deed.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</header>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FAMILY TIMELINE — magazine "sidebar" register ──────────────────
|
||||
// Hairline-ruled four-column grid. No cards, no avatars in gradient
|
||||
// boxes. The homepage's HarvestEditorial data strip is the structural
|
||||
// model; the era + name + role + bio columns are the editorial content.
|
||||
// Order matters: founder first, then by generation.
|
||||
const FAMILY = [
|
||||
{
|
||||
initials: "JH",
|
||||
name: "John Harold",
|
||||
role: "Founder",
|
||||
era: "1970s",
|
||||
bio: "Started selling Olathe's sweet corn directly from pickups. Built Tuxedo Corn into a family operation rooted in the Uncompahgre Valley.",
|
||||
},
|
||||
{
|
||||
initials: "DH",
|
||||
name: "David Harold",
|
||||
role: "Second Generation",
|
||||
era: "Present",
|
||||
bio: "Leads sustainability across Harold farms — drip irrigation, Fair Food Program, reduced chemical inputs. Keeps the soil honest.",
|
||||
},
|
||||
{
|
||||
initials: "JW",
|
||||
name: "John William Harold",
|
||||
role: "Mexico Operations",
|
||||
era: "1990s–Present",
|
||||
bio: "Three decades in Guaymas, Sonora, growing sweet corn, onions, wheat, and vegetables. Extends the Harold tradition across borders.",
|
||||
},
|
||||
{
|
||||
initials: "JH",
|
||||
name: "Joseph Harold",
|
||||
role: "Third Generation",
|
||||
era: "Present",
|
||||
bio: "Works the corn shed in summer, handles photography and beverages. Carrying the family tradition into the next generation.",
|
||||
},
|
||||
];
|
||||
|
||||
function FamilyTimeline() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 pb-24 sm:pb-32 overflow-hidden">
|
||||
<LayoutContainer>
|
||||
{/* Hairline above the strip — the magazine sidebar opening. */}
|
||||
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mb-14" />
|
||||
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10">
|
||||
{FAMILY.map((member, i) => (
|
||||
<FadeOnScroll from="up" delay={i * 0.08} key={member.name}>
|
||||
<li className="relative lg:pt-2">
|
||||
{/* Per-column hairline (top on desktop, absent on mobile
|
||||
because the gap-y supplies spacing). */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 -top-6 h-px bg-stone-900/15 hidden lg:block"
|
||||
/>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 tabular-nums">
|
||||
{member.era}
|
||||
</p>
|
||||
<p className="mt-4 font-display text-stone-950 text-[clamp(1.65rem,2.4vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-700/85">
|
||||
{member.role}
|
||||
</p>
|
||||
<p className="mt-4 text-stone-600 text-sm leading-[1.6] max-w-[28ch]">
|
||||
{member.bio}
|
||||
</p>
|
||||
</li>
|
||||
</FadeOnScroll>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{/* Hairline + caption underneath, matching the HarvestEditorial
|
||||
editorial-footer rhythm. */}
|
||||
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mt-16" />
|
||||
<p className="mt-8 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
|
||||
Three generations · One valley · Forty summers
|
||||
</p>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── VISIT THE FARM — dark editorial address block ──────────────────
|
||||
// Replaces the three icon-card contact layout. Stone-950 panel with a
|
||||
// magazine "by the numbers" data strip + a single hairline-ruled
|
||||
// address line. No SVG icons in colored boxes; the typography carries
|
||||
// the section.
|
||||
function VisitTheFarm({ address }: { address: string }) {
|
||||
return (
|
||||
<section
|
||||
className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden"
|
||||
aria-labelledby="visit-heading"
|
||||
>
|
||||
{/* Single amber rim at the top — same hinge used on the dark
|
||||
homepage sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
|
||||
The Farm
|
||||
</p>
|
||||
<h2
|
||||
id="visit-heading"
|
||||
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Visit the<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/85">Uncompahgre Valley.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
The farm is open during harvest. Pickup windows are tight,
|
||||
shed hours are short, and the corn is best the morning it’s
|
||||
picked — call ahead before you drive out.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hairline-ruled data strip — four columns: address / phone /
|
||||
shipping hours / office hours. Same magazine-sidebar treatment
|
||||
as the homepage HarvestEditorial numbers strip, just with
|
||||
prose instead of counters. */}
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10 border-t border-b border-amber-300/15">
|
||||
<FarmPoint label="Address" detail={address} mono />
|
||||
<FarmPoint label="Shipping" detail="970-323-5631" mono />
|
||||
<FarmPoint label="Office" detail="970-323-6874" mono />
|
||||
<FarmPoint
|
||||
label="Hours"
|
||||
detail={["Mon–Fri · 7A–6P", "Sat–Sun · 8A–2P"]}
|
||||
/>
|
||||
</ol>
|
||||
|
||||
{/* Editorial caption, italic display serif. */}
|
||||
<p className="mt-16 sm:mt-20 max-w-2xl mx-auto text-center font-display italic text-stone-300 text-[clamp(1.1rem,1.8vw,1.4rem)] leading-[1.45]">
|
||||
“We don’t ship corn that’s more than a day from
|
||||
the field. The valley won’t let us.”
|
||||
</p>
|
||||
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// One column of the Visit-the-Farm strip. Same hairline rules and
|
||||
// label style as HarvestEditorial's DataPoint on the homepage, with
|
||||
// `detail` either a string or an array of lines (for the Hours column).
|
||||
function FarmPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string | string[];
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const lines = Array.isArray(detail) ? detail : [detail];
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-amber-300/85">
|
||||
{label}
|
||||
</p>
|
||||
{lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`mt-4 text-stone-100 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CTA — closing editorial pair ───────────────────────────────────
|
||||
// Stone-50 section with the dark gradient emerald→amber pill button
|
||||
// used on the homepage StorySection "Read Our Story" CTA, plus a
|
||||
// secondary "Shop Products" outline. Same voice as the homepage's
|
||||
// closing beat: "Find a stop / Shop products".
|
||||
function CTASection() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="relative mx-auto max-w-3xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
This Week
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4vw,3rem)] leading-[1.05] tracking-[-0.018em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Find a stop near you,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">or have it shipped.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.24}>
|
||||
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
|
||||
Preorder for pickup at a stop on the summer route, or have
|
||||
cooler boxes shipped directly to your door after the season.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.32}>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/tuxedo#stops"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50"
|
||||
>
|
||||
<span>Find a Stop</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tuxedo#products"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-white px-9 py-4 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<span>Shop Products</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type BrandSettings = {
|
||||
invoice_business_name: string | null;
|
||||
@@ -22,6 +24,11 @@ type FormState = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
const FALLBACK_ADDRESS = "59751 David Road, Olathe, CO 81425";
|
||||
const FALLBACK_EMAIL = "orders@tuxedocorn.com";
|
||||
const SHIPPING_PHONE = "970-323-5631";
|
||||
const OFFICE_PHONE = "970-323-6874";
|
||||
|
||||
export default function TuxedoContactPage() {
|
||||
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -30,12 +37,16 @@ export default function TuxedoContactPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
import("@/lib/supabase").then(({ supabase }) => {
|
||||
supabase
|
||||
.from("wholesale_settings")
|
||||
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
|
||||
.select(
|
||||
"invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website"
|
||||
)
|
||||
.eq("brand_id", TUXEDO_BRAND_ID)
|
||||
.single()
|
||||
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
@@ -47,182 +58,531 @@ export default function TuxedoContactPage() {
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const address = brandSettings?.invoice_business_address ?? FALLBACK_ADDRESS;
|
||||
const email = brandSettings?.invoice_business_email ?? FALLBACK_EMAIL;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<main>
|
||||
<ContactHero />
|
||||
|
||||
{/* Contact info strip — hairline-ruled four-column data grid,
|
||||
same magazine-sidebar register as the About page's
|
||||
"Visit the Farm" section. */}
|
||||
<ContactInfoStrip
|
||||
address={address}
|
||||
email={email}
|
||||
shippingPhone={SHIPPING_PHONE}
|
||||
officePhone={OFFICE_PHONE}
|
||||
/>
|
||||
|
||||
{/* Form section — cream stone-50, two-column layout. Left
|
||||
column carries the editorial framing (eyebrow + Fraunces
|
||||
headline + paragraph + small hours caption). Right column
|
||||
holds the form itself, no wrapper white card. */}
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-16">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Get in Touch</p>
|
||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Contact Us
|
||||
</h1>
|
||||
<p className="mt-5 text-lg text-stone-500 max-w-xl mx-auto leading-relaxed">
|
||||
Questions about corn, stops, or wholesale accounts — we are happy to help.
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16">
|
||||
{/* Left: editorial framing */}
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Send a Message
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact info grid */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
|
||||
<p className="text-sm text-stone-500 leading-relaxed">
|
||||
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Phone</h3>
|
||||
<a href="tel:9703235631" className="block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
|
||||
Shipping: 970-323-5631
|
||||
</a>
|
||||
<a href="tel:9703236874" className="mt-1 block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
|
||||
Office: 970-323-6874
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
|
||||
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact form */}
|
||||
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Send a Message</p>
|
||||
<h2 className="text-3xl font-black text-stone-950 tracking-tight leading-tight">
|
||||
Get in Touch
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.2vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Tell us what you’re after,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">we’ll write back.</span>
|
||||
</h2>
|
||||
<div className="mt-4 h-px w-12 bg-emerald-600" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.24}>
|
||||
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
|
||||
Most messages get a reply within one business day. During
|
||||
harvest weeks the office is open early — call if your
|
||||
order is time-sensitive and we’ll get you a real
|
||||
answer, not a form letter.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
{/* Hours caption, hairline-ruled inline. */}
|
||||
<FadeOnScroll from="up" delay={0.32}>
|
||||
<dl className="mt-12 border-t border-stone-900/15 pt-6 grid grid-cols-2 gap-y-5 gap-x-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
Weekdays
|
||||
</dt>
|
||||
<dd className="mt-2 font-mono tabular-nums text-stone-950">
|
||||
7A – 6P
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
Weekends
|
||||
</dt>
|
||||
<dd className="mt-2 font-mono tabular-nums text-stone-950">
|
||||
8A – 2P
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="right">
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl bg-emerald-50 p-10 text-center ring-1 ring-emerald-100">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-4">
|
||||
<svg className="h-6 w-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-950">Message received.</p>
|
||||
<p className="mt-2 text-sm text-stone-500">We will be in touch within 1-2 business days.</p>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
<FormSuccess
|
||||
onReset={() => {
|
||||
setSubmitted(false);
|
||||
setForm({ name: "", email: "", topic: "general", message: "" });
|
||||
}}
|
||||
className="mt-5 text-sm font-medium text-emerald-700 underline hover:text-emerald-900"
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
|
||||
<input id="fld-1-your-name" aria-label="Jane Smith"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
|
||||
placeholder="Jane Smith"
|
||||
<ContactForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-email" className="block text-sm font-semibold text-stone-700 mb-2">Email</label>
|
||||
<input id="fld-2-email" aria-label="Jane@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label>
|
||||
<select id="fld-3-topic" aria-label="Select"
|
||||
value={form.topic}
|
||||
onChange={(e) => setForm({ ...form, topic: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors appearance-none"
|
||||
>
|
||||
<option value="general">General Question</option>
|
||||
<option value="orders">Orders & Pickup</option>
|
||||
<option value="wholesale">Wholesale Inquiry</option>
|
||||
<option value="media">Media & Partnership</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-4-message" className="block text-sm font-semibold text-stone-700 mb-2">Message</label>
|
||||
<textarea id="fld-4-message" aria-label="How Can We Help You?"
|
||||
required
|
||||
rows={5}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors resize-none"
|
||||
placeholder="How can we help you?"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-xl bg-stone-900 px-8 py-4 text-sm font-bold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:shadow-lg hover:shadow-black/15"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Business hours — elevated */}
|
||||
<div className="mt-10 rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="mb-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Summer Season</p>
|
||||
<h2 className="text-2xl font-black text-stone-950 tracking-tight leading-tight">
|
||||
When to Reach Us
|
||||
</h2>
|
||||
<div className="mt-4 h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
|
||||
<p className="font-semibold text-stone-700">Monday – Friday</p>
|
||||
<p className="text-stone-500 font-medium">7:00 AM – 6:00 PM</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
|
||||
<p className="font-semibold text-stone-700">Saturday – Sunday</p>
|
||||
<p className="text-stone-500 font-medium">8:00 AM – 2:00 PM</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-stone-400 leading-relaxed">
|
||||
During the off-season, response times may be longer. For urgent matters, please call the office.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
<ContactClosingQuote />
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HERO — dark editorial opener ────────────────────────────────────
|
||||
// Mirrors the About page's hero structure: eyebrow + Fraunces headline
|
||||
// (with italic emphasis) + subhead + hairline gradient rule. The
|
||||
// field photograph sits as a soft half-bleed at the bottom-right
|
||||
// instead of a featured portrait — the contact page is shorter and
|
||||
// quieter than the about page.
|
||||
function ContactHero() {
|
||||
return (
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
{/* Soft field image, anchored bottom-right, partially visible
|
||||
behind the headline column. Suggests the farm without
|
||||
competing with the form. */}
|
||||
<div className="absolute inset-y-0 right-0 w-full lg:w-1/2 -z-10">
|
||||
<Image
|
||||
src={TUXEDO_IMAGES.heroField}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
quality={85}
|
||||
priority
|
||||
className="object-cover object-center filter-editorial opacity-50"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-stone-950 via-stone-950/85 to-stone-950/30" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/40 via-transparent to-stone-950" />
|
||||
</div>
|
||||
{/* Single amber rim along the bottom — same hinge as the
|
||||
homepage and about dark sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Get in Touch · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Send a note,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">or a question.</span>
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
Orders, pickup logistics, wholesale accounts, press — every
|
||||
message lands on a real desk in Olathe and gets a real
|
||||
answer.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">Replies in 1–2 days</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">No form letters</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Real people</span>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CONTACT INFO STRIP — magazine "by the numbers" pattern ─────────
|
||||
// Same hairline-ruled four-column data grid as the About page's
|
||||
// "Visit the Farm" section. Address, shipping phone, office phone,
|
||||
// email — no icon-in-rounded-box, just type and tabular numerals.
|
||||
function ContactInfoStrip({
|
||||
address,
|
||||
email,
|
||||
shippingPhone,
|
||||
officePhone,
|
||||
}: {
|
||||
address: string;
|
||||
email: string;
|
||||
shippingPhone: string;
|
||||
officePhone: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-20 sm:py-24">
|
||||
<LayoutContainer>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-12 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
|
||||
<InfoPoint label="Address" detail={address} />
|
||||
<InfoPoint label="Shipping" detail={shippingPhone} mono />
|
||||
<InfoPoint label="Office" detail={officePhone} mono />
|
||||
<InfoPoint label="Email" detail={email} mono />
|
||||
</ol>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FORM — bottom-border-only editorial inputs ─────────────────────
|
||||
// The form sits directly on the cream background without a wrapping
|
||||
// card. Inputs use bottom borders only (with a hairline focus state),
|
||||
// matching the "atelier-input" treatment used elsewhere in the admin.
|
||||
// The submit button is the same emerald→amber gradient pill used on
|
||||
// the homepage and about pages.
|
||||
function ContactForm({
|
||||
form,
|
||||
setForm,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (s: FormState) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-10">
|
||||
<div className="grid gap-10 sm:grid-cols-2">
|
||||
<Field
|
||||
id="fld-name"
|
||||
label="Your Name"
|
||||
value={form.name}
|
||||
onChange={(v) => setForm({ ...form, name: v })}
|
||||
required
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
<Field
|
||||
id="fld-email"
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => setForm({ ...form, email: v })}
|
||||
required
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
id="fld-topic"
|
||||
label="Topic"
|
||||
value={form.topic}
|
||||
onChange={(v) => setForm({ ...form, topic: v })}
|
||||
options={[
|
||||
{ value: "general", label: "General Question" },
|
||||
{ value: "orders", label: "Orders & Pickup" },
|
||||
{ value: "wholesale", label: "Wholesale Inquiry" },
|
||||
{ value: "media", label: "Media & Partnership" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
id="fld-message"
|
||||
label="Message"
|
||||
value={form.message}
|
||||
onChange={(v) => setForm({ ...form, message: v })}
|
||||
required
|
||||
placeholder="How can we help?"
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||
>
|
||||
<span>{isSubmitting ? "Sending…" : "Send Message"}</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Single text input — bottom-border-only editorial treatment.
|
||||
// Label sits above the input as a small uppercase eyebrow so the
|
||||
// field is readable on the cream background without a chrome card.
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
required,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full appearance-none bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 pr-8 text-base text-stone-950 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
required={required}
|
||||
rows={5}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSuccess({ onReset }: { onReset: () => void }) {
|
||||
return (
|
||||
<div className="relative border-t border-b border-emerald-700/30 py-16 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-6">
|
||||
<svg
|
||||
className="h-6 w-6 text-emerald-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-display text-stone-950 text-[clamp(1.5rem,2.6vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
|
||||
Message received.
|
||||
</p>
|
||||
<div className="mx-auto mt-6 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 leading-[1.65]">
|
||||
We’ll be in touch within one to two business days. If it’s
|
||||
urgent, the office line is the fastest way to reach us.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="mt-8 text-sm font-semibold text-emerald-700 underline underline-offset-4 hover:text-emerald-900 transition-colors"
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CLOSING — dark editorial quote ─────────────────────────────────
|
||||
// Same magazine-closing beat the homepage uses: italic Fraunces quote
|
||||
// centered with a hairline-rule attribution underneath.
|
||||
function ContactClosingQuote() {
|
||||
return (
|
||||
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
|
||||
{/* Top amber rim — hinges against the cream form section. */}
|
||||
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-amber-300/15" />
|
||||
|
||||
<LayoutContainer>
|
||||
<FadeOnScroll from="up">
|
||||
<p
|
||||
className="mx-auto max-w-2xl text-center font-display italic text-stone-100 text-[clamp(1.4rem,2.8vw,2.1rem)] leading-[1.4] tracking-[-0.012em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
“Pick up the phone. Write us a note. Either way, you’re
|
||||
reaching a person who knows the field.”
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mx-auto mt-8 h-px w-12 bg-amber-300/70" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.25}>
|
||||
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-400">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.35}>
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/tuxedo/faq"
|
||||
className="text-sm font-semibold text-amber-200/85 underline underline-offset-4 hover:text-amber-100 transition-colors"
|
||||
>
|
||||
Read the FAQ first →
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import ContactClientPage from "./ContactClientPage";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -40,5 +39,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function TuxedoContactLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ContactClientPage />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ContactClientPage from "./ContactClientPage";
|
||||
|
||||
export default function TuxedoContactPage() {
|
||||
return <ContactClientPage />;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type FAQItem = {
|
||||
question: string;
|
||||
@@ -16,15 +18,18 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "How do I place a preorder for corn?",
|
||||
answer: "Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
|
||||
answer:
|
||||
"Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
|
||||
},
|
||||
{
|
||||
question: "Can I order without selecting a pickup stop?",
|
||||
answer: "Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
|
||||
answer:
|
||||
"Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I miss my pickup time?",
|
||||
answer: "Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
|
||||
answer:
|
||||
"Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -33,15 +38,18 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "Do you ship corn?",
|
||||
answer: "Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
|
||||
answer:
|
||||
"Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
|
||||
},
|
||||
{
|
||||
question: "How are cooler boxes packaged?",
|
||||
answer: "Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
|
||||
answer:
|
||||
"Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
|
||||
},
|
||||
{
|
||||
question: "When will my shipped order arrive?",
|
||||
answer: "Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 3-7 business days.",
|
||||
answer:
|
||||
"Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 3–7 business days.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -50,11 +58,13 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "How do I set up a wholesale account?",
|
||||
answer: "Visit /wholesale/register to apply. We review applications and respond within 1-2 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
|
||||
answer:
|
||||
"Visit /wholesale/register to apply. We review applications and respond within 1–2 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
|
||||
},
|
||||
{
|
||||
question: "Is there a minimum order for wholesale?",
|
||||
answer: "Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
|
||||
answer:
|
||||
"Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -63,21 +73,30 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "What makes Olathe Sweet different?",
|
||||
answer: "Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
|
||||
answer:
|
||||
"Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
|
||||
},
|
||||
{
|
||||
question: "Is your corn organic?",
|
||||
answer: "We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
|
||||
answer:
|
||||
"We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
|
||||
},
|
||||
{
|
||||
question: "How should I store my corn?",
|
||||
answer: "Store corn in the refrigerator and eat within 3-5 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
|
||||
answer:
|
||||
"Store corn in the refrigerator and eat within 3–5 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: string }) {
|
||||
function FAQAccordion({
|
||||
items,
|
||||
searchQuery,
|
||||
}: {
|
||||
items: FAQItem[];
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -90,47 +109,74 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl bg-white p-6 sm:p-8 text-center shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-stone-300">
|
||||
<p className="text-stone-500 text-sm">No results for "{searchQuery}"</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-stone-500 text-sm">
|
||||
No results for “{searchQuery}”
|
||||
</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">
|
||||
Try a different term or browse the categories below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-stone-900/15 border-t border-b border-stone-900/15">
|
||||
{filtered.map((item) => {
|
||||
const isOpen = open === item.question;
|
||||
return (
|
||||
<div
|
||||
key={item.question}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
|
||||
>
|
||||
<button type="button"
|
||||
<li key={item.question}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : item.question)}
|
||||
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
|
||||
className="group flex w-full items-start justify-between gap-6 py-6 sm:py-7 text-left"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="font-semibold text-stone-950 pr-3 sm:pr-4 text-sm sm:text-[15px] leading-snug group-hover:text-emerald-700 transition-colors">
|
||||
<span className="font-display text-stone-950 text-[clamp(1.1rem,1.7vw,1.35rem)] leading-[1.3] tracking-[-0.01em] pr-4 group-hover:text-emerald-800 transition-colors">
|
||||
{item.question}
|
||||
</span>
|
||||
<span className={`flex-shrink-0 w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-all duration-300 ${isOpen ? "bg-emerald-600 text-white rotate-180" : "bg-stone-100 text-stone-400 group-hover:bg-stone-200"}`}>
|
||||
<svg className="w-3 h-3 sm:w-3.5 sm:h-3.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1 flex-shrink-0 inline-flex h-7 w-7 items-center justify-center transition-all duration-200 ${
|
||||
isOpen
|
||||
? "rotate-45 text-emerald-700"
|
||||
: "text-stone-400 group-hover:text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}>
|
||||
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100">
|
||||
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-out ${
|
||||
isOpen
|
||||
? "grid-rows-[1fr] opacity-100 pb-6 sm:pb-7"
|
||||
: "grid-rows-[0fr] opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-stone-600 leading-[1.7] max-w-3xl pr-10">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,63 +187,183 @@ export default function TuxedoFAQPage() {
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
<main className="py-12 sm:py-16 md:py-20 px-4 sm:px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-10 sm:mb-14">
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3 sm:mb-4">Questions</p>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-tight">FAQ</h1>
|
||||
<p className="mt-4 sm:mt-5 text-base sm:text-lg text-stone-500 leading-relaxed">Everything you need to know about ordering, pickup, and shipping.</p>
|
||||
<main>
|
||||
{/* Hero — cream stone-50 with editorial framing. */}
|
||||
<section className="relative bg-stone-50 pt-20 pb-12 sm:pt-24 sm:pb-16 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Help · Frequently Asked
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-950 text-[clamp(2.5rem,5.2vw,4.25rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Questions,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">answered.</span>
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mx-auto mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-600 text-lg sm:text-xl leading-[1.65]">
|
||||
Everything you need to know about ordering, pickup, and
|
||||
shipping — straight from the people who pack the boxes.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-8 sm:mb-12 relative">
|
||||
<div className="absolute inset-y-0 left-4 sm:left-5 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
{/* Search — editorial bottom-border-only input. */}
|
||||
<section className="relative bg-stone-50 py-12 sm:py-16">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<FadeOnScroll from="up">
|
||||
<div className="relative">
|
||||
<label htmlFor="faq-search" className="sr-only">
|
||||
Search questions
|
||||
</label>
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-4 w-4 text-stone-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input aria-label="Search Questions..."
|
||||
<input
|
||||
id="faq-search"
|
||||
type="text"
|
||||
placeholder="Search questions..."
|
||||
placeholder="Search questions…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white pl-10 sm:pl-11 pr-5 py-3.5 sm:py-4 text-sm sm:text-base text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900/20 transition-all duration-200 shadow-sm hover:shadow-md focus:shadow-lg"
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 pl-7 pr-9 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute inset-y-0 right-4 flex items-center text-stone-400 hover:text-stone-600 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
aria-label="Clear search"
|
||||
className="absolute inset-y-0 right-0 flex items-center text-stone-400 hover:text-stone-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* FAQ categories */}
|
||||
<div className="space-y-10 sm:space-y-14">
|
||||
{/* Categories — each rendered as a category with hairline-ruled
|
||||
accordion list. No card wrappers; each item is a single row. */}
|
||||
<section className="relative bg-stone-50 pb-24 sm:pb-32">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl space-y-16 sm:space-y-20">
|
||||
{FAQ_CATEGORIES.map((cat) => (
|
||||
<div key={cat.category}>
|
||||
<h2 className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.2em] text-stone-400 mb-4 sm:mb-5">{cat.category}</h2>
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 mb-6">
|
||||
{cat.category}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FAQAccordion items={cat.items} searchQuery={searchQuery} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Still have questions CTA */}
|
||||
<div className="mt-12 sm:mt-16 rounded-2xl sm:rounded-3xl bg-stone-950 px-6 sm:px-10 py-10 sm:py-12 text-center shadow-xl">
|
||||
<p className="text-xl sm:text-2xl font-black text-white tracking-tight">Still have questions?</p>
|
||||
<p className="mt-2 text-stone-400 text-sm">We are happy to help — reach out anytime.</p>
|
||||
<Link href="/tuxedo/contact" className="mt-6 sm:mt-8 inline-flex items-center gap-2 rounded-full bg-emerald-600 px-6 sm:px-8 py-3 sm:py-4 font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-all text-sm tracking-wider hover:shadow-lg hover:shadow-emerald-900/30">
|
||||
Contact Us
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
{/* Closing CTA — dark editorial panel with italic Fraunces
|
||||
headline + Contact button. Same closing beat the homepage
|
||||
and about pages use. */}
|
||||
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
|
||||
{/* Top amber rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
|
||||
Still Wondering
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Still have
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/85">questions?</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-7 text-stone-400 text-lg leading-[1.65]">
|
||||
We’re happy to help — reach out anytime. Most
|
||||
messages get a reply within one business day.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/tuxedo/contact"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
|
||||
>
|
||||
<span>Contact Us</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import FAQClientPage from "./FAQClientPage";
|
||||
import { serializeJsonForScript } from "@/lib/safe-json";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
@@ -144,7 +143,7 @@ export default function TuxedoFAQLayout({ children }: { children: React.ReactNod
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonForScript(organizationJsonLd) }}
|
||||
/>
|
||||
<FAQClientPage />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+473
-390
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import type { PublicStop } from "@/actions/stops";
|
||||
|
||||
type Props = {
|
||||
@@ -16,22 +18,30 @@ type Props = {
|
||||
|
||||
export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [now] = useState(() => new Date());
|
||||
|
||||
// Subtle scroll-in stagger for the stops list. The Motion policy in
|
||||
// ScrollAnimations.tsx caps translation to 16px / 320ms, which matches
|
||||
// what we want here — the list reveals as a quiet beat, not a show.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
gsap.from(".stop-row", {
|
||||
y: 16,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
duration: 0.32,
|
||||
stagger: 0.04,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top 90%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter((s) => new Date(s.date) < now);
|
||||
|
||||
@@ -39,123 +49,315 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4">
|
||||
{brandName}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" />
|
||||
<main>
|
||||
{/* Hero — dark editorial opener with the field photograph as a
|
||||
soft half-bleed at the bottom, same register as the
|
||||
Contact page hero. */}
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
{/* Soft field image, bottom-anchored, partially visible
|
||||
behind the headline column. */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/3 -z-10">
|
||||
<Image
|
||||
src={TUXEDO_IMAGES.heroField}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
quality={85}
|
||||
priority
|
||||
className="object-cover object-center filter-editorial opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950 via-stone-950/70 to-stone-950" />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
{upcomingStops.length === 0 ? (
|
||||
<div className="max-w-2xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops Yet</h2>
|
||||
<p className="text-stone-500 mb-8 max-w-md mx-auto">
|
||||
The corn is still ripening in the field. New pickup stops are added every week — check back soon, or browse the full season schedule.
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
{brandName} · Summer 2026
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-6 py-3 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Back to Tuxedo Corn
|
||||
</Link>
|
||||
Stops
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">near you.</span>
|
||||
</h1>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65]">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop
|
||||
near you. Preorder for pickup, or just show up — we’ll
|
||||
have coolers on the truck.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">
|
||||
{upcomingStops.length} upcoming
|
||||
</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Updated weekly</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-6 py-3 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
className="text-stone-400 hover:text-amber-200/80 underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Download Full Schedule
|
||||
Download full schedule
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Stops list — hairline-ruled magazine sidebar style. Each
|
||||
row is a single horizontal hairline + date numerals + city
|
||||
+ location + time + chevron. No cards, no gradients. */}
|
||||
<section className="relative bg-stone-50 py-20 sm:py-24">
|
||||
<LayoutContainer>
|
||||
{upcomingStops.length === 0 ? (
|
||||
<EmptyStops />
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Eyebrow + hairline-ruled list header */}
|
||||
<div className="flex items-baseline justify-between gap-4 mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700">
|
||||
Upcoming
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
|
||||
{upcomingStops.length} stops
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="border-t border-stone-900/15">
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex flex-col items-center justify-center text-white shadow-md shadow-emerald-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short" })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center group-hover:bg-emerald-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-emerald-600 group-hover:text-white transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<StopRow key={stop.id} stop={stop} brandSlug={brandSlug} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
<div className="mt-20">
|
||||
<div className="flex items-baseline justify-between gap-4 mb-8">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-stone-400">
|
||||
Past Stops
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
|
||||
Last {Math.min(5, pastStops.length)}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="border-t border-stone-900/10 opacity-70">
|
||||
{pastStops.slice(0, 5).map((stop) => (
|
||||
<div
|
||||
<StopRow
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
stop={stop}
|
||||
brandSlug={brandSlug}
|
||||
past
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── STOP ROW — hairline-ruled magazine-sidebar cell ────────────────
|
||||
// Date numerals on the left (large tabular day, small month above),
|
||||
// city/state + location + time in the middle, chevron on the right.
|
||||
// All hairline-divided rows; the list reads as a sidebar, not a feed.
|
||||
function StopRow({
|
||||
stop,
|
||||
brandSlug,
|
||||
past = false,
|
||||
}: {
|
||||
stop: PublicStop;
|
||||
brandSlug: string;
|
||||
past?: boolean;
|
||||
}) {
|
||||
const date = new Date(stop.date);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
|
||||
|
||||
const content = (
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-6 items-center py-5 sm:py-6 group">
|
||||
{/* Date column — big tabular day, small month/weekday above */}
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<p
|
||||
className={`text-[10px] uppercase tracking-[0.28em] tabular-nums ${
|
||||
past ? "text-stone-400" : "text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
{weekday} · {month}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-1 font-display tabular-nums leading-none tracking-[-0.02em] ${
|
||||
past
|
||||
? "text-stone-400 text-3xl sm:text-4xl"
|
||||
: "text-stone-950 text-4xl sm:text-5xl"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* City + location */}
|
||||
<div className="col-span-9 sm:col-span-6 min-w-0">
|
||||
<h3
|
||||
className={`font-display leading-[1.15] tracking-[-0.012em] ${
|
||||
past
|
||||
? "text-stone-500 text-base sm:text-lg"
|
||||
: "text-stone-950 text-lg sm:text-xl group-hover:text-emerald-800 transition-colors"
|
||||
}`}
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 truncate ${past ? "text-stone-400 text-sm" : "text-stone-500 text-sm"}`}
|
||||
>
|
||||
{stop.location}
|
||||
</p>
|
||||
{stop.address && (
|
||||
<p
|
||||
className={`mt-0.5 truncate ${past ? "text-stone-400 text-xs" : "text-stone-400 text-xs"}`}
|
||||
>
|
||||
{stop.address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time + cutoff */}
|
||||
<div className="col-span-8 sm:col-span-3 text-right">
|
||||
<p
|
||||
className={`font-mono tabular-nums text-sm ${
|
||||
past ? "text-stone-400" : "text-stone-950"
|
||||
}`}
|
||||
>
|
||||
{stop.time}
|
||||
</p>
|
||||
{!past && stop.cutoff_time && (
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
|
||||
Order by{" "}
|
||||
{new Date(stop.cutoff_time).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{past && (
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
|
||||
Completed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron — only on upcoming rows */}
|
||||
<div className="hidden sm:flex col-span-1 justify-end">
|
||||
{!past && (
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-300 group-hover:text-emerald-700 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<li className="stop-row border-b border-stone-900/15 last:border-b-0">
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
|
||||
if (past) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-row block border-b border-stone-900/15 last:border-b-0 hover:bg-stone-100/60 transition-colors"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EMPTY STATE — editorial Fraunces italic copy ──────────────────
|
||||
// Same "no stops yet" message as before, but rendered as a quiet
|
||||
// editorial panel instead of an icon-in-rounded-box illustration.
|
||||
function EmptyStops() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl text-center py-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Coming Soon
|
||||
</p>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
The corn is still ripening
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">in the field.</span>
|
||||
</h2>
|
||||
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
|
||||
New pickup stops are added every week. Check back soon, or browse
|
||||
the full season schedule below.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-8 py-3.5 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
|
||||
>
|
||||
<span>Back to Tuxedo Corn</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Download Schedule
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -31,30 +32,25 @@ type Stop = {
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
cutoff_time?: string | null;
|
||||
address?: string | null;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export default function StopPage() {
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
const BRAND_SLUG = "tuxedo";
|
||||
|
||||
export default function TuxedoStopPage() {
|
||||
const params = useParams();
|
||||
const slug = params.id as string;
|
||||
|
||||
const [stop, setStop] = useState<{
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
} | null>(null);
|
||||
const [stop, setStop] = useState<Stop | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [brandSlug, setBrandSlug] = useState("tuxedo");
|
||||
const [brandAccent, setBrandAccent] = useState<"green" | "orange" | "blue">("green");
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
setProducts(result.products as unknown as Product[]);
|
||||
}
|
||||
@@ -64,38 +60,89 @@ export default function StopPage() {
|
||||
if (!stop) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
<main className="py-20">
|
||||
<StorefrontHeader
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
<main className="py-32">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<h1 className="text-5xl font-black tracking-tight text-stone-950">Stop Not Found</h1>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-700 mb-5">
|
||||
Off the Route
|
||||
</p>
|
||||
<h1
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
That stop isn’t{" "}
|
||||
<span className="italic text-amber-700/90">on the calendar.</span>
|
||||
</h1>
|
||||
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 leading-[1.65]">
|
||||
It may have passed, or the URL is off by a character.
|
||||
Browse the full list to find a stop near you.
|
||||
</p>
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/tuxedo/stops"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-stone-950 px-8 py-3.5 text-sm font-bold text-white hover:bg-stone-800 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
<span>All Stops</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
<StorefrontFooter
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isBlue = brandAccent === "blue";
|
||||
const brandLabel = isBlue ? "Indian River Direct" : "Tuxedo Corn";
|
||||
const date = new Date(stop.date);
|
||||
const day = date.getDate();
|
||||
const weekday = date.toLocaleDateString("en-US", { weekday: "long" });
|
||||
const monthYear = date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StopSetEffect stop={stop} />
|
||||
<StorefrontHeader
|
||||
brandName={brandLabel}
|
||||
brandSlug={brandSlug}
|
||||
brandAccent={brandAccent}
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<main>
|
||||
{/* Back nav — kept slim, no white card wrapper. */}
|
||||
<div className="bg-stone-50 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
|
||||
{/* Back navigation */}
|
||||
<div className="mb-10 flex items-center gap-2">
|
||||
<div className="py-5 flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href={`/${brandSlug}#stops`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-stone-500 hover:text-stone-800 transition-colors group"
|
||||
href={`/${BRAND_SLUG}#stops`}
|
||||
className="flex items-center gap-2 text-stone-500 hover:text-stone-950 transition-colors group"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform duration-200 group-hover:-translate-x-1"
|
||||
@@ -103,89 +150,164 @@ export default function StopPage() {
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
All Stops
|
||||
</Link>
|
||||
<span className="text-stone-300">/</span>
|
||||
<span className="text-sm text-stone-700 font-medium">{stop.city}, {stop.state}</span>
|
||||
<span className="text-stone-700 font-medium truncate">
|
||||
{stop.city}, {stop.state}
|
||||
</span>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{/* Stop header */}
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">
|
||||
{brandLabel}
|
||||
{/* Hero — dark editorial opener. Eyebrow + huge Fraunces
|
||||
city/state headline with the date numerals set as part of
|
||||
the type composition, not as a separate stat block. */}
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-end">
|
||||
{/* Left: copy + headline */}
|
||||
<div className="lg:col-span-8">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Pickup Stop · {monthYear}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-stone-950 leading-[1.0]">
|
||||
{stop.city},<br className="hidden md:block" /> {stop.state}
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.75rem,7vw,5.5rem)] leading-[0.98] tracking-[-0.025em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
{stop.city},
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">
|
||||
{stop.state}.
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-lg text-stone-500 leading-relaxed">
|
||||
{isBlue
|
||||
? "Order fresh Florida citrus for pickup at this stop."
|
||||
: "Order fresh Olathe Sweet™ sweet corn for pickup at this stop."}
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
Order fresh Olathe Sweet™ sweet corn for pickup
|
||||
at this stop. We’ll have it on the truck the
|
||||
morning of — ready when you are.
|
||||
</p>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="rounded-3xl bg-white p-8 mb-14 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-xl hover:ring-stone-300/60">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Date</p>
|
||||
<p className="font-bold text-stone-950">{formatDate(stop.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Time</p>
|
||||
<p className="font-bold text-stone-950">{stop.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Location</p>
|
||||
<p className="font-bold text-stone-950 leading-tight">{stop.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: date as a big editorial stamp. */}
|
||||
<div className="lg:col-span-4 lg:text-right">
|
||||
<FadeOnScroll from="right">
|
||||
<p className="text-[10px] uppercase tracking-[0.32em] text-amber-300/70 mb-2 tabular-nums">
|
||||
{weekday}
|
||||
</p>
|
||||
<p className="font-display tabular-nums text-stone-50 text-[clamp(4rem,8vw,6.5rem)] leading-none tracking-[-0.03em]">
|
||||
{day}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] uppercase tracking-[0.28em] text-stone-400 tabular-nums">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Available Products — editorial header */}
|
||||
<section>
|
||||
<div className="mb-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Available Products
|
||||
{/* Stop info — hairline-ruled four-column data grid. Date /
|
||||
Time / Location / Order by. Same magazine-sidebar register
|
||||
as the homepage's HarvestEditorial numbers strip. */}
|
||||
<section className="relative bg-stone-50 py-16 sm:py-20 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<ol className="grid grid-cols-2 lg:grid-cols-4 gap-y-10 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
|
||||
<StopInfoPoint label="Date" detail={formatDate(stop.date)} />
|
||||
<StopInfoPoint label="Time" detail={stop.time} mono />
|
||||
<StopInfoPoint label="Location" detail={stop.location} />
|
||||
<StopInfoPoint
|
||||
label="Order By"
|
||||
detail={
|
||||
stop.cutoff_time
|
||||
? new Date(stop.cutoff_time).toLocaleTimeString(
|
||||
"en-US",
|
||||
{ hour: "numeric", minute: "2-digit" }
|
||||
)
|
||||
: "Day-of"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Available Products — cream section with editorial framing,
|
||||
same register as the homepage ProductsSection header. */}
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Farm-Direct · This Stop
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Preorder
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">
|
||||
for this stop.
|
||||
</span>
|
||||
</h2>
|
||||
<div className="mt-5 h-px w-12 bg-emerald-600" />
|
||||
<p className="mt-5 text-base text-stone-500 leading-relaxed">
|
||||
Preorder for pickup — add items below and we will have them ready at the stop.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
Add items below and we’ll have them ready at
|
||||
the stop. Preordering helps us bring the right
|
||||
amount of corn to each pickup.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white p-12 text-center shadow-sm ring-1 ring-stone-200/60">
|
||||
<p className="text-stone-500">No products available for this stop.</p>
|
||||
<div className="border-t border-b border-stone-900/15 py-16 text-center">
|
||||
<p className="text-stone-500">
|
||||
No products available for this stop yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div className="grid gap-x-8 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -195,26 +317,53 @@ export default function StopPage() {
|
||||
price={`$${product.price}`}
|
||||
type={product.type}
|
||||
imageUrl={product.image_url}
|
||||
brandSlug={brandSlug}
|
||||
brandName={brandLabel}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandName={BRAND_NAME}
|
||||
brandId={product.brand_id}
|
||||
brandAccent={brandAccent}
|
||||
brandAccent="green"
|
||||
is_taxable={product.is_taxable}
|
||||
pickup_type={product.pickup_type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brandLabel}
|
||||
brandSlug={brandSlug}
|
||||
brandAccent={brandAccent}
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One column of the stop-info data strip — same hairline + label
|
||||
// treatment as the homepage's HarvestEditorial DataPoint.
|
||||
function StopInfoPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { brandId: string };
|
||||
|
||||
export default function WaterAdminPinClient({ brandId }: Props) {
|
||||
export default function WaterAdminPinClient() {
|
||||
const router = useRouter();
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -21,7 +19,7 @@ export default function WaterAdminPinClient({ brandId }: Props) {
|
||||
const res = await fetch("/api/water-admin-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, pin }),
|
||||
body: JSON.stringify({ pin }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import WaterAdminPinClient from "./WaterAdminPinClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
/**
|
||||
* Tuxedo-brand-specific water admin login.
|
||||
*
|
||||
* The 4-digit admin PIN gates `/water/admin/*` for the Tuxedo brand.
|
||||
* Users must be able to enter the PIN WITHOUT being signed into the
|
||||
* platform — this is a PIN-only entry point for brand staff in the
|
||||
* field. The auth itself lives in `src/lib/water-admin-pin-auth.ts`,
|
||||
* which is self-contained (no Neon Auth).
|
||||
*
|
||||
* If you need a similar portal for another brand, duplicate this
|
||||
* page under a different slug (e.g. `/water/admin-tuxedo/login`) and
|
||||
* update the brand UUID in `water-admin-pin-auth.ts`.
|
||||
*/
|
||||
export default async function WaterAdminLoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex flex-col items-center justify-center p-6">
|
||||
@@ -13,7 +23,7 @@ export default async function WaterAdminLoginPage() {
|
||||
<h1 className="text-2xl font-bold text-slate-900">Water Log Admin</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Enter your 4-digit admin PIN</p>
|
||||
</div>
|
||||
<WaterAdminPinClient brandId={TUXEDO_BRAND_ID} />
|
||||
<WaterAdminPinClient />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getWaterDisplaySummary,
|
||||
getWaterHeadgatesAdmin,
|
||||
getWaterIrrigators,
|
||||
getWaterAlertLog,
|
||||
} from "@/actions/water-log/admin";
|
||||
import { getAdminSession, TUXEDO_BRAND_ID } from "@/lib/water-admin-pin-auth";
|
||||
import WaterAdminClient from "@/components/water/WaterAdminClient";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function WaterAdminPage() {
|
||||
const cookieStore = await cookies();
|
||||
const adminSession = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!adminSession) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
redirect("/water/admin/login");
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,49 @@
|
||||
/**
|
||||
* Public `/water` route — entrypoint for the mobile-first Tuxedo
|
||||
* Water Log PIN-gated experience.
|
||||
*
|
||||
* As of 2026-07, the route renders `WaterFieldClient`, which now
|
||||
* delegates to the Apple-HIG styled `MobileWaterApp` state machine
|
||||
* (PIN → Headgates → Log → Success). All screens are designed
|
||||
* for phones and tablets in the field — there is no desktop
|
||||
* consideration.
|
||||
*
|
||||
* `display: "standalone"` + `viewportFit: "cover"` in the root
|
||||
* layout, combined with the mobile-specific `<meta>` tags below,
|
||||
* give this route a near-native feel when added to an iOS home
|
||||
* screen as a PWA.
|
||||
*/
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import WaterFieldClient from "@/components/water/WaterFieldClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Water Log · Tuxedo Corn",
|
||||
description:
|
||||
"Field log of headgate water readings — PIN-protected, mobile-only.",
|
||||
robots: {
|
||||
index: false, // PIN-gated field tool — no public indexing
|
||||
follow: false,
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Water Log",
|
||||
},
|
||||
};
|
||||
|
||||
// The water route is mobile-only — never scale above 1.0 (we don't
|
||||
// want desktop users pinching-and-zooming a tool that's tuned for
|
||||
// thumb-reach). The viewport-fit: cover setting in the root layout
|
||||
// enables safe-area insets on iOS.
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: "#14532d",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function WaterPage() {
|
||||
return <WaterFieldClient />;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
export default function CTASection() {
|
||||
return (
|
||||
<section className="py-20 bg-white relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-100 to-transparent" />
|
||||
|
||||
<LayoutContainer>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="relative text-center"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tight text-stone-950">Ready to Order?</h2>
|
||||
<p className="mt-4 text-stone-500 text-lg">Experience the difference four decades of excellence makes.</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/tuxedo#stops"
|
||||
className="group relative inline-flex items-center gap-3 rounded-full bg-stone-900 px-8 py-4 font-bold text-white overflow-hidden"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-emerald-700 to-emerald-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<span className="relative flex items-center gap-2">
|
||||
Find a Stop
|
||||
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tuxedo#products"
|
||||
className="group relative inline-flex items-center gap-3 rounded-full bg-white px-8 py-4 font-bold text-stone-900 ring-2 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<span className="relative">
|
||||
Shop Products
|
||||
<svg className="inline w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
interface ContactSectionProps {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function ContactSection({ address }: ContactSectionProps) {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-950 text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-0 left-1/3 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/3 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-400/60">Get in Touch</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
|
||||
Visit Our Farm
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-12 max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Address</p>
|
||||
<p className="text-stone-300 leading-relaxed">{address}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Phone</p>
|
||||
<p className="text-stone-300">Shipping: 970-323-5631</p>
|
||||
<p className="text-stone-300 mt-1">Office: 970-323-6874</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Hours</p>
|
||||
<p className="text-stone-300">M–F: 7AM–6PM</p>
|
||||
<p className="text-stone-300 mt-1">Sat & Sun: 8AM–2PM</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
const FAMILY_MEMBERS = [
|
||||
{
|
||||
initials: "JH",
|
||||
name: "John Harold",
|
||||
role: "Founder",
|
||||
bio: "John Harold began selling Olathe's sweet corn directly from pickups in the 1970s. With unwavering dedication to community, family, and agriculture, he built Tuxedo Corn into a thriving family operation. Prior to farming, John established the first Group Home on the Western Slope and opened Colorow Care Center in Montrose County.",
|
||||
era: "1970s",
|
||||
},
|
||||
{
|
||||
initials: "DH",
|
||||
name: "David Harold",
|
||||
role: "Second Generation",
|
||||
bio: "David leads sustainability efforts across Harold farms, implementing drip irrigation and joining the Fair Food Program to ensure responsible labor practices. He continues the family's commitment to raising top-quality corn while protecting the land for future generations.",
|
||||
era: "Present",
|
||||
},
|
||||
{
|
||||
initials: "JW",
|
||||
name: "John William Harold",
|
||||
role: "Mexico Operations",
|
||||
bio: "John William has worked in Guaymas, Sonora, Mexico for over three decades growing sweet corn, onions, wheat, and vegetables. His international operations extend the Harold family farming tradition across borders.",
|
||||
era: "1990s–Present",
|
||||
},
|
||||
{
|
||||
initials: "JH",
|
||||
name: "Joseph Harold",
|
||||
role: "Third Generation",
|
||||
bio: "Joseph works at the corn shed during summer and handles photography and beverages, carrying the family tradition into the next generation while learning every aspect of the business.",
|
||||
era: "Present",
|
||||
},
|
||||
];
|
||||
|
||||
// Family member card with hover effects
|
||||
function FamilyCard({ member, index }: { member: typeof FAMILY_MEMBERS[0]; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.6, delay: index * 0.12, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative"
|
||||
>
|
||||
{/* Era badge */}
|
||||
<div className="absolute -top-3 -left-3 z-10">
|
||||
<span className="inline-block bg-stone-900 text-stone-200 text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full shadow-lg">
|
||||
{member.era}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-gradient-to-b from-white to-stone-50 rounded-3xl p-8 shadow-lg shadow-black/5 ring-1 ring-stone-200/40 overflow-hidden h-full">
|
||||
{/* Decorative top line */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-500 origin-left"
|
||||
/>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6 relative">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-stone-900 to-stone-800 flex items-center justify-center shadow-xl shadow-stone-900/30">
|
||||
<span className="text-2xl font-black text-white tracking-tight">{member.initials}</span>
|
||||
</div>
|
||||
{/* Decorative corner accent */}
|
||||
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-emerald-600/20 rounded-full blur-sm" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-stone-950 tracking-tight">{member.name}</h3>
|
||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-widest text-emerald-700">{member.role}</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-stone-600">{member.bio}</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom accent */}
|
||||
<div className="absolute bottom-0 left-8 right-8 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FamilyTimelineSection() {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-100 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(16,100,50,0.08)_0%,transparent_50%)]" />
|
||||
|
||||
<LayoutContainer>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">The People Behind the Corn</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
The Harold Family
|
||||
</h2>
|
||||
<div className="mt-8 mx-auto h-px w-24 bg-gradient-to-r from-emerald-600 via-emerald-400 to-transparent" />
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
|
||||
{FAMILY_MEMBERS.map((member, index) => (
|
||||
<FamilyCard key={member.name} member={member} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
export default function MissionSection() {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-50 relative overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-100/30 rounded-full blur-3xl -z-10" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">Our Mission</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Raising the Best Corn<br />in the World
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.15, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="prose prose-lg"
|
||||
>
|
||||
<p className="text-stone-600 leading-relaxed text-lg">
|
||||
Tuxedo Corn is the <strong className="text-stone-900">exclusive grower and shipper</strong> of Olathe Sweet Sweet Corn — the only corn that carries that prestigious name. When you order from us, you receive the real thing: grown by the only family that planted roots in Olathe, Colorado over forty years ago.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.25, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="prose prose-lg"
|
||||
>
|
||||
<p className="text-stone-600 leading-relaxed text-lg">
|
||||
Not all sweet corn is equal. Olathe Sweet was developed for our high-altitude mountain climate, where <strong className="text-stone-900">intense summer sun by day meets cool mountain air at night</strong>. That combination coaxes extraordinary sweetness and tenderness out of every single ear.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="mt-16 bg-white rounded-3xl p-10 shadow-lg shadow-stone-900/5 ring-1 ring-stone-200/50"
|
||||
>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-14 h-14 rounded-2xl bg-emerald-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-7 h-7 text-emerald-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-950 mb-3">Grown with Future Generations in Mind</h3>
|
||||
<p className="text-stone-600 leading-relaxed">
|
||||
We farm regeneratively — no-till and minimal-till methods protect soil structure, cover crops naturally cycle nutrients, and reduced chemical inputs keep our mountain water pure. Every ear is hand-picked at peak freshness, inspected by our dedicated crew. Not machine-harvested. Not sorted by strangers. <strong className="text-stone-900">Hand-picked.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ type FormState = {
|
||||
showTextAlerts: boolean;
|
||||
schedulePdfNotes: string;
|
||||
heroImageUrl: string;
|
||||
heroVideoUrl: string;
|
||||
brandPrimaryColor: string;
|
||||
brandSecondaryColor: string;
|
||||
brandBgColor: string;
|
||||
@@ -189,6 +190,7 @@ function buildInitialFormState(settings: BrandSettings | null, brandName: string
|
||||
showTextAlerts: settings?.show_text_alerts ?? false,
|
||||
schedulePdfNotes: settings?.schedule_pdf_notes ?? "",
|
||||
heroImageUrl: settings?.hero_image_url ?? "",
|
||||
heroVideoUrl: settings?.hero_video_url ?? "",
|
||||
brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a",
|
||||
brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4",
|
||||
brandBgColor: settings?.brand_bg_color ?? "#fafaf9",
|
||||
@@ -258,6 +260,7 @@ function BrandSettingsFormBody({
|
||||
showTextAlerts: state.showTextAlerts,
|
||||
schedulePdfNotes: state.schedulePdfNotes || undefined,
|
||||
heroImageUrl: state.heroImageUrl || undefined,
|
||||
heroVideoUrl: state.heroVideoUrl || undefined,
|
||||
brandPrimaryColor: state.brandPrimaryColor || undefined,
|
||||
brandSecondaryColor: state.brandSecondaryColor || undefined,
|
||||
brandBgColor: state.brandBgColor || undefined,
|
||||
@@ -324,6 +327,7 @@ function BrandSettingsFormBody({
|
||||
<StorefrontSection
|
||||
heroTagline={state.heroTagline}
|
||||
heroImageUrl={state.heroImageUrl}
|
||||
heroVideoUrl={state.heroVideoUrl}
|
||||
aboutHeadline={state.aboutHeadline}
|
||||
aboutSubheadline={state.aboutSubheadline}
|
||||
customFooterText={state.customFooterText}
|
||||
@@ -673,6 +677,7 @@ function EmailInvoiceSection({
|
||||
function StorefrontSection({
|
||||
heroTagline,
|
||||
heroImageUrl,
|
||||
heroVideoUrl,
|
||||
aboutHeadline,
|
||||
aboutSubheadline,
|
||||
customFooterText,
|
||||
@@ -680,6 +685,7 @@ function StorefrontSection({
|
||||
}: {
|
||||
heroTagline: string;
|
||||
heroImageUrl: string;
|
||||
heroVideoUrl: string;
|
||||
aboutHeadline: string;
|
||||
aboutSubheadline: string;
|
||||
customFooterText: string;
|
||||
@@ -719,6 +725,18 @@ function StorefrontSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminInput
|
||||
label="Hero Video URL (optional)"
|
||||
helpText="mp4 or webm. Plays muted, looped, full-bleed behind the hero copy. If unset or fails to load, the hero falls back to the Hero Image URL above."
|
||||
>
|
||||
<AdminTextInput
|
||||
type="url"
|
||||
value={heroVideoUrl}
|
||||
onChange={(e) => setField("heroVideoUrl", e.target.value)}
|
||||
placeholder="https://cdn.example.com/hero-corn-fields.mp4"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="About Page Headline">
|
||||
<AdminTextInput
|
||||
value={aboutHeadline}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import { useReducer, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
getReportsSummary,
|
||||
getOrdersByStopReport,
|
||||
@@ -317,7 +317,19 @@ export default function ReportsDashboard({
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState);
|
||||
|
||||
const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd);
|
||||
// Memoize range — `buildRange` returns a new object each call (it uses
|
||||
// `new Date()` for the end-date). Without this memo, `range` would have a
|
||||
// fresh identity every render, which makes `fetchAll` a fresh identity
|
||||
// every render (it's a useCallback depending on `range`), which makes the
|
||||
// `useEffect(() => fetchAll(), [fetchAll])` below refire every render and
|
||||
// dispatch SET_LOADING/SET_REPORTS, which re-renders → infinite loop.
|
||||
// Memoizing on the primitive inputs pins range to user-driven changes
|
||||
// only; the day-rollover case is intentionally not handled here (would
|
||||
// require a separate timer to refresh `today`).
|
||||
const range: DateRange = useMemo(
|
||||
() => buildRange(state.preset, state.customStart, state.customEnd),
|
||||
[state.preset, state.customStart, state.customEnd]
|
||||
);
|
||||
|
||||
// Year is read from the clock; compute in the browser only to avoid
|
||||
// hydration mismatches with the server. The setState is wrapped in an
|
||||
|
||||
@@ -135,14 +135,17 @@ export default function SettingsClient({
|
||||
paymentSettings,
|
||||
currentUser,
|
||||
}: Props) {
|
||||
// Initial tab is determined by URL hash if present, otherwise "general".
|
||||
// No useEffect needed — we read window.location.hash during the lazy
|
||||
// initializer on the client. On the server we default to "general".
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
if (typeof window === "undefined") return "general";
|
||||
// Server renders the default tab; client picks up the URL hash after mount
|
||||
// so the initial server HTML matches the first client render (avoids
|
||||
// hydration mismatch). Reading window.location.hash in a lazy initializer
|
||||
// is unsafe because the server has no hash and would render a different
|
||||
// tab than the client.
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return TABS.find((t) => t.hash === hash)?.id ?? "general";
|
||||
});
|
||||
const tab = TABS.find((t) => t.hash === hash)?.id;
|
||||
if (tab) setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ export default function AdminFilterTabs({
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
|
||||
flex overflow-x-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
|
||||
${sizes.container} ${className}
|
||||
`}
|
||||
role="tablist"
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SmartsheetIntegrationCard — `/admin/water-log/settings` per-feature
|
||||
* card for the Water Log → Smartsheet integration.
|
||||
*
|
||||
* 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 + backfill + recent activity.
|
||||
*
|
||||
* Visual language matches the parent Water Log settings page
|
||||
* (cream + forest palette, "Field Almanac" style).
|
||||
*
|
||||
* Threading brandId: takes `brandId` as a prop per CLAUDE.md ("Brand ID
|
||||
* Threading"). The settings page still hardcodes TUXEDO_BRAND_ID; this
|
||||
* card accepts it as a prop so the page can be lifted later without
|
||||
* rewrites.
|
||||
*/
|
||||
|
||||
import { useEffect, useReducer, useState, useCallback } from "react";
|
||||
import {
|
||||
getSmartsheetConfig,
|
||||
saveSmartsheetConfig,
|
||||
testSmartsheetConnection,
|
||||
listSmartsheetSyncLog,
|
||||
enqueueSmartsheetBackfill,
|
||||
type EnqueueBackfillResult,
|
||||
type SmartsheetConfigResponse,
|
||||
type SmartsheetSyncLogEntry,
|
||||
} from "@/actions/water-log/smartsheet";
|
||||
import type {
|
||||
SmartsheetColumnMapping,
|
||||
SmartsheetFrequency,
|
||||
} from "@/db/schema/water-log";
|
||||
import { formatDateTime } from "@/lib/format-date";
|
||||
|
||||
const COLUMN_LABELS: Record<keyof SmartsheetColumnMapping, string> = {
|
||||
entry_id: "Entry ID (UUID, for dedup)",
|
||||
logged_at: "Logged at (timestamp)",
|
||||
headgate: "Headgate name",
|
||||
measurement: "Measurement",
|
||||
unit: "Unit (CFS, GPM, …)",
|
||||
irrigator: "Irrigator name",
|
||||
notes: "Notes (optional)",
|
||||
};
|
||||
|
||||
const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
|
||||
"entry_id",
|
||||
"logged_at",
|
||||
];
|
||||
|
||||
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
|
||||
realtime: "Real-time (after every entry)",
|
||||
every_15_minutes: "Every 15 minutes",
|
||||
hourly: "Hourly",
|
||||
};
|
||||
|
||||
type ColumnOption = { id: string; title: string; type: string };
|
||||
|
||||
type TestState =
|
||||
| { state: "idle" }
|
||||
| { state: "loading" }
|
||||
| {
|
||||
state: "ok";
|
||||
sheetName: string;
|
||||
columnCount: number;
|
||||
columns: ColumnOption[];
|
||||
}
|
||||
| { state: "error"; message: string };
|
||||
|
||||
type State = {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
message: { type: "success" | "error"; text: string } | null;
|
||||
savedConfig: SmartsheetConfigResponse | null;
|
||||
enabled: boolean;
|
||||
sheetIdInput: string;
|
||||
frequency: SmartsheetFrequency;
|
||||
columns: ColumnOption[];
|
||||
columnMapping: SmartsheetColumnMapping;
|
||||
test: TestState;
|
||||
recent: SmartsheetSyncLogEntry[];
|
||||
backfill: { running: boolean; result: EnqueueBackfillResult | null };
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "load"; config: SmartsheetConfigResponse; recent: SmartsheetSyncLogEntry[] }
|
||||
| { type: "loading"; loading: boolean }
|
||||
| { type: "saving"; saving: boolean }
|
||||
| { type: "message"; message: State["message"] }
|
||||
| { type: "reset-message" }
|
||||
| { type: "enabled"; value: boolean }
|
||||
| { type: "sheet"; value: string }
|
||||
| { type: "frequency"; value: SmartsheetFrequency }
|
||||
| { type: "columns"; columns: ColumnOption[] }
|
||||
| { type: "mapping"; value: SmartsheetColumnMapping }
|
||||
| { type: "test"; value: TestState }
|
||||
| { type: "backfill-running"; running: boolean }
|
||||
| { type: "backfill-result"; result: EnqueueBackfillResult | null };
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
loading: true,
|
||||
saving: false,
|
||||
message: null,
|
||||
savedConfig: null,
|
||||
enabled: false,
|
||||
sheetIdInput: "",
|
||||
frequency: "hourly",
|
||||
columns: [],
|
||||
columnMapping: {
|
||||
entry_id: "",
|
||||
logged_at: "",
|
||||
headgate: null,
|
||||
measurement: null,
|
||||
unit: null,
|
||||
irrigator: null,
|
||||
notes: null,
|
||||
},
|
||||
test: { state: "idle" },
|
||||
recent: [],
|
||||
backfill: { running: false, result: null },
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "load":
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
savedConfig: action.config,
|
||||
recent: action.recent,
|
||||
enabled: action.config.syncEnabled,
|
||||
sheetIdInput: action.config.sheetId ?? "",
|
||||
frequency: action.config.syncFrequency,
|
||||
columnMapping:
|
||||
action.config.columnMapping ?? state.columnMapping,
|
||||
};
|
||||
case "loading":
|
||||
return { ...state, loading: action.loading };
|
||||
case "saving":
|
||||
return { ...state, saving: action.saving };
|
||||
case "message":
|
||||
return { ...state, message: action.message };
|
||||
case "reset-message":
|
||||
return { ...state, message: null };
|
||||
case "enabled":
|
||||
return { ...state, enabled: action.value };
|
||||
case "sheet":
|
||||
return { ...state, sheetIdInput: action.value };
|
||||
case "frequency":
|
||||
return { ...state, frequency: action.value };
|
||||
case "columns":
|
||||
return { ...state, columns: action.columns };
|
||||
case "mapping":
|
||||
return { ...state, columnMapping: action.value };
|
||||
case "test":
|
||||
return { ...state, test: action.value };
|
||||
case "backfill-running":
|
||||
return {
|
||||
...state,
|
||||
backfill: { ...state.backfill, running: action.running },
|
||||
};
|
||||
case "backfill-result":
|
||||
return {
|
||||
...state,
|
||||
backfill: { running: false, result: action.result },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function SmartsheetIntegrationCard({ brandId }: { brandId: string }) {
|
||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||
// re-render trigger for inline status messages
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// ── Initial load ──
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
dispatch({ type: "loading", loading: true });
|
||||
const [config, recent] = await Promise.all([
|
||||
getSmartsheetConfig(brandId),
|
||||
listSmartsheetSyncLog(brandId, 10),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
dispatch({ type: "load", config, recent });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [brandId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [config, recent] = await Promise.all([
|
||||
getSmartsheetConfig(brandId),
|
||||
listSmartsheetSyncLog(brandId, 10),
|
||||
]);
|
||||
dispatch({ type: "load", config, recent });
|
||||
}, [brandId]);
|
||||
|
||||
// ── Save ──
|
||||
const onSave = useCallback(async () => {
|
||||
dispatch({ type: "saving", saving: true });
|
||||
dispatch({ type: "reset-message" });
|
||||
const result = await saveSmartsheetConfig(brandId, {
|
||||
sheetId: state.sheetIdInput,
|
||||
// Cycle 7: token is owned by the workspace; ignored on save.
|
||||
token: "",
|
||||
syncEnabled: state.enabled,
|
||||
syncFrequency: state.frequency,
|
||||
columnMapping: state.columnMapping,
|
||||
});
|
||||
if (!result.success) {
|
||||
dispatch({
|
||||
type: "message",
|
||||
message: { type: "error", text: result.error ?? "Save failed" },
|
||||
});
|
||||
dispatch({ type: "saving", saving: false });
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
dispatch({
|
||||
type: "message",
|
||||
message: { type: "success", text: "Saved." },
|
||||
});
|
||||
dispatch({ type: "saving", saving: false });
|
||||
setTick((t) => t + 1);
|
||||
}, [brandId, refresh, state.sheetIdInput, state.enabled, state.frequency, state.columnMapping]);
|
||||
|
||||
// ── Test connection ──
|
||||
const onTest = useCallback(async () => {
|
||||
if (!state.sheetIdInput) {
|
||||
dispatch({
|
||||
type: "test",
|
||||
value: { state: "error", message: "Sheet ID is required to test" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "test", value: { state: "loading" } });
|
||||
// Cycle 7: Test uses the workspace's saved token. We pass
|
||||
// token: "" so the action falls through to resolveWorkspaceToken.
|
||||
const result = await testSmartsheetConnection(
|
||||
brandId,
|
||||
state.sheetIdInput,
|
||||
"",
|
||||
);
|
||||
if (result.success) {
|
||||
dispatch({
|
||||
type: "test",
|
||||
value: {
|
||||
state: "ok",
|
||||
sheetName: result.sheetName,
|
||||
columnCount: result.columnCount,
|
||||
columns: result.columns,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "columns", columns: result.columns });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "test",
|
||||
value: { state: "error", message: result.error },
|
||||
});
|
||||
}
|
||||
}, [brandId, state.sheetIdInput]);
|
||||
|
||||
// ── Backfill ──
|
||||
const onBackfill = useCallback(async () => {
|
||||
dispatch({ type: "backfill-running", running: true });
|
||||
const result = await enqueueSmartsheetBackfill(brandId);
|
||||
dispatch({ type: "backfill-result", result });
|
||||
await refresh();
|
||||
}, [brandId, refresh]);
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
|
||||
<p className="text-sm text-[#5a5d5a]">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasWorkspaceToken = state.savedConfig?.hasToken === true;
|
||||
const maskedToken = state.savedConfig?.maskedToken ?? null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3
|
||||
className="text-lg font-medium text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Water Log → Smartsheet
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[#5a5d5a]">
|
||||
Push every new water log entry to a sheet inside the connected
|
||||
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.enabled}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "enabled", value: e.target.checked })
|
||||
}
|
||||
data-test="smartsheet-enabled"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Workspace token reference */}
|
||||
<div className="mt-4 rounded-md border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-xs text-[#5a5d5a]">
|
||||
{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>
|
||||
|
||||
{/* Sheet ID + Frequency */}
|
||||
<div className="mt-5 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-[#5a5d5a]">
|
||||
Sheet ID or URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-sm text-[#1a4d2e]"
|
||||
placeholder="e.g. 8309876543210123"
|
||||
value={state.sheetIdInput}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "sheet", value: e.target.value })
|
||||
}
|
||||
data-test="smartsheet-sheet-id"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||
Pick the sheet inside the workbook where water-log entries should land.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[#5a5d5a]">
|
||||
Frequency
|
||||
</label>
|
||||
<select
|
||||
value={state.frequency}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "frequency",
|
||||
value: e.target.value as SmartsheetFrequency,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-[#d4d9d3] bg-[#fdfaf2] px-3 py-2 text-sm"
|
||||
data-test="smartsheet-frequency"
|
||||
>
|
||||
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{FREQUENCY_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.saving || !state.sheetIdInput}
|
||||
onClick={onTest}
|
||||
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
|
||||
data-test="smartsheet-test"
|
||||
>
|
||||
{state.test.state === "loading" ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.saving}
|
||||
onClick={onSave}
|
||||
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
|
||||
data-test="smartsheet-save"
|
||||
>
|
||||
{state.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<span className="ml-2 text-xs text-[#5a5d5a]">
|
||||
{state.savedConfig?.lastSyncAt
|
||||
? `Last sync ${formatDateTime(new Date(state.savedConfig.lastSyncAt))}`
|
||||
: "Never synced"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{state.test.state === "ok" && (
|
||||
<div className="smartsheet-slide-down mt-3 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
|
||||
<p className="text-sm font-medium text-[#1a4d2e]">
|
||||
✓ Connected to “{state.test.sheetName}” — {state.test.columnCount} columns
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{state.test.state === "error" && (
|
||||
<div className="smartsheet-slide-down mt-3 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
|
||||
<p className="text-sm font-medium text-[#a4452b]">
|
||||
✗ {state.test.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.message && (
|
||||
<div
|
||||
className={`smartsheet-fade-in mt-3 rounded-lg p-3 text-sm ${
|
||||
state.message.type === "success"
|
||||
? "bg-[#dfe9d4] text-[#1a4d2e]"
|
||||
: "bg-[#f0e1d8] text-[#a4452b]"
|
||||
}`}
|
||||
>
|
||||
{state.message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column mapping */}
|
||||
{state.columns.length > 0 && (
|
||||
<div className="mt-6 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] p-4">
|
||||
<h4 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h4>
|
||||
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||
Required: <span className="font-mono">entry_id</span>,{" "}
|
||||
<span className="font-mono">logged_at</span>. Others optional.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{(Object.keys(COLUMN_LABELS) as Array<keyof SmartsheetColumnMapping>).map(
|
||||
(key) => {
|
||||
const required = REQUIRED_COLUMNS.includes(key);
|
||||
const value = state.columnMapping[key] ?? "";
|
||||
return (
|
||||
<label key={key} className="block">
|
||||
<span className="block text-xs font-medium text-[#5a5d5a]">
|
||||
{COLUMN_LABELS[key]}
|
||||
{required && <span className="ml-1 text-[#a4452b]">*</span>}
|
||||
</span>
|
||||
<select
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === "" ? null : e.target.value;
|
||||
dispatch({
|
||||
type: "mapping",
|
||||
value: {
|
||||
...state.columnMapping,
|
||||
[key]: next,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="mt-1 w-full rounded-md border border-[#d4d9d3] bg-white px-2 py-1.5 text-sm"
|
||||
data-test={`smartsheet-col-${key}`}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{state.columns.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title || `Column ${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backfill */}
|
||||
<div className="mt-6 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] p-4">
|
||||
<h4 className="text-sm font-semibold text-[#1a4d2e]">Backfill</h4>
|
||||
<p className="mt-1 text-xs text-[#5a5d5a]">
|
||||
Push every existing water log entry to the sheet. Re-runs are safe
|
||||
(existing entries are deduped by the sheet’s Entry ID column).
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.backfill.running || !state.enabled}
|
||||
onClick={onBackfill}
|
||||
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-white disabled:opacity-60"
|
||||
>
|
||||
{state.backfill.running ? "Running…" : "Backfill existing entries"}
|
||||
</button>
|
||||
{state.backfill.result && (
|
||||
<span className="text-xs text-[#5a5d5a]">
|
||||
{state.backfill.result.success
|
||||
? `Considered ${state.backfill.result.considered}, queued ${state.backfill.result.newlyQueued}, synced ${state.backfill.result.drained.synced}, remaining ${state.backfill.result.drained.remaining}`
|
||||
: state.backfill.result.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent activity */}
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-[#1a4d2e]">Recent activity</h4>
|
||||
<div className="mt-3 overflow-x-auto rounded-xl border border-[#d4d9d3]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[#5a5d5a]">
|
||||
<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>
|
||||
{state.recent.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-3 text-center text-[#5a5d5a]" colSpan={4}>
|
||||
No sync attempts yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{state.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmartsheetIntegrationCard;
|
||||
@@ -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} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user