"use client"; /** * Smartsheet Integration — `/admin/water-log/settings` section. * * Self-contained card that lets a brand admin configure the Smartsheet * integration for their Water Log entries: * * 1. Enable/disable the integration * 2. Smartsheet sheet URL or numeric sheet ID * 3. API token (password field; never sent back to the client after save) * 4. Sync frequency (realtime / every 15 minutes / hourly) * 5. Column mapping (entry_id, logged_at, headgate, measurement, * unit, irrigator, notes) * 6. Test Connection button — fetches sheet metadata so the column * dropdowns are populated * 7. Recent Activity panel — last 10 sync attempts (green ✓ / red ✗) * * Visual language matches the parent Water Log settings page * (cream + forest palette, "Field Almanac" style). * * Threading brandId: * The card takes `brandId` as a prop per CLAUDE.md ("Brand ID * Threading"). The current Water Log 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 = { 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", ]; // IDs for the in-page section nav. Hoisted to module scope so the // IntersectionObserver effect can list it once in its deps array. const SECTION_IDS = [ "smartsheet-enable", "smartsheet-connection", "smartsheet-frequency", "smartsheet-mapping", "smartsheet-backfill", "smartsheet-activity", ] as const; type SectionId = (typeof SECTION_IDS)[number]; const FREQUENCY_LABELS: Record = { 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; // Persisted (saved) values — for displaying the masked token, etc. savedConfig: SmartsheetConfigResponse | null; // Editable form fields: enabled: boolean; sheetIdInput: string; tokenInput: string; // empty string = keep existing showToken: boolean; frequency: SmartsheetFrequency; mapping: SmartsheetColumnMapping; // Available sheet columns (loaded via Test Connection) availableColumns: ColumnOption[]; // Test Connection state test: TestState; // Recent activity recentLog: SmartsheetSyncLogEntry[]; loadingLog: boolean; /** Whether the setup-instructions modal is open. */ setupModalOpen: boolean; /** Backfill UI state. */ backfill: { running: boolean; range: "all" | "30d" | "7d"; result: EnqueueBackfillResult | null; }; }; type Action = | { type: "LOAD_START" } | { type: "LOAD_DONE"; config: SmartsheetConfigResponse; log: SmartsheetSyncLogEntry[] } | { type: "SAVE_START" } | { type: "SAVE_OK"; config: SmartsheetConfigResponse } | { type: "SAVE_FAIL"; message: string } | { type: "SAVE_DONE" } | { type: "SET_ENABLED"; value: boolean } | { type: "SET_SHEET_ID"; value: string } | { type: "SET_TOKEN"; value: string } | { type: "TOGGLE_SHOW_TOKEN" } | { type: "SET_FREQUENCY"; value: SmartsheetFrequency } | { type: "SET_MAPPING_KEY"; key: keyof SmartsheetColumnMapping; value: string } | { type: "TEST_START" } | { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] } | { type: "TEST_FAIL"; message: string } | { type: "CLEAR_MESSAGE" } | { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] } | { type: "OPEN_SETUP_MODAL" } | { type: "CLOSE_SETUP_MODAL" } | { type: "BACKFILL_START" } | { type: "BACKFILL_RANGE"; value: "all" | "30d" | "7d" } | { type: "BACKFILL_DONE"; result: EnqueueBackfillResult }; const EMPTY_MAPPING: SmartsheetColumnMapping = { entry_id: "", logged_at: "", headgate: "", measurement: "", unit: "", irrigator: "", notes: null, }; const initialState: State = { loading: true, saving: false, message: null, savedConfig: null, enabled: false, sheetIdInput: "", tokenInput: "", showToken: false, frequency: "hourly", mapping: { ...EMPTY_MAPPING }, availableColumns: [], test: { state: "idle" }, recentLog: [], loadingLog: false, setupModalOpen: false, backfill: { running: false, range: "all", result: null }, }; function reducer(state: State, action: Action): State { switch (action.type) { case "LOAD_START": return { ...state, loading: true }; case "LOAD_DONE": { const c = action.config; return { ...state, loading: false, savedConfig: c, enabled: c.syncEnabled, sheetIdInput: c.sheetId ?? "", // tokenInput stays empty — we don't preload from saved. frequency: c.syncFrequency, mapping: c.columnMapping ?? { ...EMPTY_MAPPING }, recentLog: action.log, }; } case "SAVE_START": return { ...state, saving: true, message: null }; case "SAVE_OK": return { ...state, saving: false, message: { type: "success", text: "Smartsheet settings saved" }, savedConfig: action.config, // Clear token input on save (token is now stored server-side). tokenInput: "", showToken: false, }; case "SAVE_FAIL": return { ...state, saving: false, message: { type: "error", text: action.message }, }; case "SAVE_DONE": return { ...state, saving: false }; case "SET_ENABLED": return { ...state, enabled: action.value }; case "SET_SHEET_ID": return { ...state, sheetIdInput: action.value }; case "SET_TOKEN": return { ...state, tokenInput: action.value }; case "TOGGLE_SHOW_TOKEN": return { ...state, showToken: !state.showToken }; case "SET_FREQUENCY": return { ...state, frequency: action.value }; case "SET_MAPPING_KEY": return { ...state, mapping: { ...state.mapping, [action.key]: action.value === "" ? null : action.value, } as SmartsheetColumnMapping, }; case "TEST_START": return { ...state, test: { state: "loading" } }; case "TEST_OK": return { ...state, test: { state: "ok", sheetName: action.sheetName, columnCount: action.columns.length, columns: action.columns, }, availableColumns: action.columns, }; case "TEST_FAIL": return { ...state, test: { state: "error", message: action.message } }; case "CLEAR_MESSAGE": return { ...state, message: null }; case "REFRESH_LOG": return { ...state, recentLog: action.log }; case "OPEN_SETUP_MODAL": return { ...state, setupModalOpen: true }; case "CLOSE_SETUP_MODAL": return { ...state, setupModalOpen: false }; case "BACKFILL_START": return { ...state, backfill: { ...state.backfill, running: true }, }; case "BACKFILL_RANGE": return { ...state, backfill: { ...state.backfill, range: action.value }, }; case "BACKFILL_DONE": return { ...state, backfill: { ...state.backfill, running: false, result: action.result, }, }; default: return state; } } export default function SmartsheetIntegrationCard({ brandId, }: { brandId: string; }) { const [state, dispatch] = useReducer(reducer, initialState); const loadAll = useCallback(async () => { dispatch({ type: "LOAD_START" }); const [config, log] = await Promise.all([ getSmartsheetConfig(brandId), listSmartsheetSyncLog(brandId, 10), ]); dispatch({ type: "LOAD_DONE", config, log }); }, [brandId]); useEffect(() => { void loadAll(); }, [loadAll]); // Close the setup modal on Escape. Listens only while the modal is open. useEffect(() => { if (!state.setupModalOpen) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") { dispatch({ type: "CLOSE_SETUP_MODAL" }); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [state.setupModalOpen]); // ── In-page section nav: which section is currently visible ──────────── // The "active zone" is the top 20% of the viewport (under the sticky nav). // When a section enters that zone, it becomes active. We keep the previous // active section during the gap between sections so the highlight doesn't // blink off when scrolling between two adjacent cards. const [activeSection, setActiveSection] = useState( "smartsheet-enable", ); useEffect(() => { const elements = SECTION_IDS.map((id) => document.getElementById(id)).filter( (el): el is HTMLElement => el !== null, ); if (elements.length === 0) return; const visible = new Set(); let lastActive: SectionId | null = null; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { visible.add(entry.target.id); } else { visible.delete(entry.target.id); } } // Among visible sections, prefer the earliest (topmost) in DOM order. const ordered = SECTION_IDS.filter((id) => visible.has(id)); const nextActive = (ordered[0] ?? lastActive) as SectionId | null; if (nextActive && nextActive !== lastActive) { lastActive = nextActive; setActiveSection(nextActive); } }, { // Top 20% of the viewport = the visible "reading zone" under the nav. rootMargin: "0px 0px -80% 0px", threshold: 0, }, ); for (const el of elements) observer.observe(el); return () => observer.disconnect(); }, []); // Smooth scroll on pill click. `scroll-behavior: smooth` is set globally on // and the prefers-reduced-motion media query in globals.css flips // it to `auto`, so we don't need a JS-side matchMedia check here. const handleSectionClick = useCallback( (e: React.MouseEvent, id: string) => { const el = document.getElementById(id); if (!el) return; e.preventDefault(); el.scrollIntoView({ block: "start" }); // Reflect the destination in the URL without triggering a jump. if (typeof window !== "undefined" && window.history) { window.history.replaceState(null, "", `#${id}`); } }, [], ); async function handleSave(e: React.FormEvent) { e.preventDefault(); dispatch({ type: "SAVE_START" }); const result = await saveSmartsheetConfig(brandId, { sheetId: state.sheetIdInput, token: state.tokenInput, syncEnabled: state.enabled, syncFrequency: state.frequency, columnMapping: state.mapping, }); if (result.success && result.config) { dispatch({ type: "SAVE_OK", config: result.config }); // Refresh log so any pending activity from this turn is visible. const log = await listSmartsheetSyncLog(brandId, 10); dispatch({ type: "REFRESH_LOG", log }); } else { dispatch({ type: "SAVE_FAIL", message: result.error ?? "Save failed" }); } } async function handleTest() { if (!state.sheetIdInput) { dispatch({ type: "TEST_FAIL", message: "Enter a Sheet URL or ID first" }); return; } if (!state.tokenInput && !state.savedConfig?.hasToken) { dispatch({ type: "TEST_FAIL", message: "Paste your API token to test, or save the config first", }); return; } dispatch({ type: "TEST_START" }); const result = await testSmartsheetConnection( brandId, state.sheetIdInput, state.tokenInput, ); if (result.success) { dispatch({ type: "TEST_OK", sheetName: result.sheetName, columns: result.columns, }); } else { dispatch({ type: "TEST_FAIL", message: result.error }); } } async function handleBackfill() { if (state.backfill.running) return; if (!state.savedConfig?.configured || !state.enabled) { // Surface as a notice rather than call into the action just to fail. dispatch({ type: "BACKFILL_DONE", result: { success: false, error: "Save and enable your Smartsheet config first.", considered: 0, newlyQueued: 0, alreadyQueued: 0, drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 }, }, }); return; } const rangeLabel = state.backfill.range === "30d" ? "the last 30 days" : state.backfill.range === "7d" ? "the last 7 days" : "all time"; if ( !window.confirm( `Sync ${rangeLabel} of water log entries to Smartsheet? This is safe to re-run — already-pushed entries are deduped by Entry ID.`, ) ) { return; } dispatch({ type: "BACKFILL_START" }); const since = state.backfill.range === "30d" ? new Date(Date.now() - 30 * 86_400_000) : state.backfill.range === "7d" ? new Date(Date.now() - 7 * 86_400_000) : undefined; const result = await enqueueSmartsheetBackfill(brandId, { since }); dispatch({ type: "BACKFILL_DONE", result }); // Refresh the log so the drain results show up immediately. const log = await listSmartsheetSyncLog(brandId, 10); dispatch({ type: "REFRESH_LOG", log }); } if (state.loading) { return (

Reading saved configuration.

); } const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0; return (
{state.message && (
{state.message.type === "success" ? ( ) : ( )}

{state.message.text}

)} dispatch({ type: "SET_ENABLED", value: v })} /> dispatch({ type: "OPEN_SETUP_MODAL" })} className="inline-flex min-h-[32px] items-center justify-center rounded-lg border border-[#d4d9d3] bg-white px-3.5 py-1.5 text-[12.5px] font-semibold text-[#5a5d5a] motion-safe:transition-[background-color,border-color,color] motion-safe:duration-150 hover:border-[#1a4d2e] hover:bg-[#f4f1e8] hover:text-[#1a4d2e] focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/20 active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out" > Setup instructions } > {/* Sheet */}
dispatch({ type: "SET_SHEET_ID", value: e.target.value }) } placeholder="https://app.smartsheet.com/sheets/AbCdEfGhIjKlMn or 123456789012345" className="block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0] shadow-[inset_0_1px_0_rgba(15,23,18,0.02)] outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150 focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15" autoComplete="off" />

Paste the share URL from your browser address bar, or the numeric sheet ID. If a share URL doesn't work, use the numeric ID from File → Properties in Smartsheet.

{/* Token */}
dispatch({ type: "SET_TOKEN", value: e.target.value }) } placeholder={ state.savedConfig?.hasToken ? `Leave blank to keep the saved token (${state.savedConfig.maskedToken ?? "••••"})` : "Paste your Smartsheet API token" } className="min-h-[44px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0] shadow-[inset_0_1px_0_rgba(15,23,18,0.02)] outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150 focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15" autoComplete="off" />

Generate a token at{" "} app.smartsheet.com {" "} → Account →{" "} Personal Settings →{" "} API Access. The token is encrypted with AES-256-GCM at rest.

{/* Test Connection */}
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => ( ))}
{state.availableColumns.length === 0 ? (

Run Test Connection above to load the sheet's columns. You can also paste column IDs manually below.

) : (

Loaded {state.availableColumns.length} columns from sheet{" "} {state.test.state === "ok" ? state.test.sheetName : "—"} . Pick from the dropdowns below.

)}
{(Object.keys(COLUMN_LABELS) as Array).map( (key) => { const required = REQUIRED_COLUMNS.includes(key); const value = state.mapping[key]; const valueAsString = value ?? ""; return (
{state.availableColumns.length > 0 ? ( ) : ( dispatch({ type: "SET_MAPPING_KEY", key, value: e.target.value, }) } placeholder="Column ID (numeric)" className="min-h-[40px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 font-mono 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" /> )}
); }, )}
dispatch({ type: "BACKFILL_RANGE", value: v })} /> {state.loadingLog ? (

Loading…

) : state.recentLog.length === 0 ? (

No sync attempts yet. Save the config and submit a water log entry from /water.

) : (
    {state.recentLog.map((log) => (
  • {log.success ? "✓" : "✗"}
    {log.action} {formatDateTime(log.createdAt)} {log.durationMs != null && ( {log.durationMs}ms )}
    {log.error && (

    {log.error}

    )}
  • ))}
)}
{noTokenYet && (

Paste an API token above to enable Save.

)} {state.setupModalOpen && ( dispatch({ type: "CLOSE_SETUP_MODAL" })} /> )} ); } // ── Subcomponents (mirror the parent settings page style) ────────────────── function SectionNav({ activeId, onNavigate, }: { activeId: string; onNavigate: (e: React.MouseEvent, id: string) => void; }) { const sections: Array<{ id: string; label: string }> = [ { id: "smartsheet-enable", label: "Enable" }, { id: "smartsheet-connection", label: "Connection" }, { id: "smartsheet-frequency", label: "Frequency" }, { id: "smartsheet-mapping", label: "Mapping" }, { id: "smartsheet-backfill", label: "Backfill" }, { id: "smartsheet-activity", label: "Activity" }, ]; return ( ); } function Card({ id, title, subtitle, action, children, }: { id?: string; title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; }) { return (

{title}

{subtitle && (

{subtitle}

)}
{action &&
{action}
}
{children}
); } function ToggleRow({ label, description, value, onChange, }: { label: string; description?: string; value: boolean; onChange: (v: boolean) => void; }) { return (

{label}

{description && (

{description}

)}
); } function TestResultPanel({ test }: { test: TestState }) { if (test.state === "idle") return null; if (test.state === "loading") { return ( Testing connection… ); } if (test.state === "ok") { return (

Connected to “{test.sheetName}”{" "} — {test.columnCount} columns loaded

); } return (

{test.message}

); } // ── Backfill card ────────────────────────────────────────────────────────── type BackfillState = { running: boolean; range: "all" | "30d" | "7d"; result: EnqueueBackfillResult | null; }; function BackfillCard({ id, state, onRun, onRangeChange, }: { id?: string; state: BackfillState; onRun: () => void; onRangeChange: (value: "all" | "30d" | "7d") => void; }) { const result = state.result; // Success block — counts from the inline drain that ran after enqueue. const showResult = result && result.success; const showError = result && !result.success; return (
{state.running && (

Counting entries, enqueueing, and draining one batch inline. Any remaining entries will be drained by the cron (every 15 minutes).

)} {showError && (

{result.error}

)} {showResult && result && (

Found {result.considered}{" "} {result.considered === 1 ? "entry" : "entries"} matching the range {result.considered > 0 && ( <> {" "} ({result.newlyQueued} new, {result.alreadyQueued} already queued). )}

{(result.drained.synced + result.drained.skipped + result.drained.failed > 0 || result.drained.remaining > 0) && (

Inline drain: {result.drained.synced} synced /{" "} {result.drained.skipped} skipped / {result.drained.failed}{" "} failed. {result.drained.remaining > 0 && ( <> {" "} {result.drained.remaining} still queued {" "} — they'll drain on the next cron tick (within 15 minutes). )} {result.drained.remaining === 0 && result.considered > 0 && ( <> All entries are now in Smartsheet. )}

)}
)}
); } // ── Setup instructions modal ─────────────────────────────────────────────── const FIELD_SCHEMA: Array<{ key: keyof SmartsheetColumnMapping; label: string; required: boolean; type: string; example: string; description: string; }> = [ { key: "entry_id", label: "Entry ID", required: true, type: "TEXT_NUMBER", example: "550e8400-e29b-41d4-a716-446655440000", description: "Unique UUID for each water log row. We use this for dedup — if the same Entry ID is already in your sheet, we skip the insert instead of creating a duplicate row.", }, { key: "logged_at", label: "Logged At", required: true, type: "DATETIME (or DATE)", example: "2026-07-03 14:50", description: "When the reading was taken. Use a DATETIME column for full timestamp, or DATE for day-only. We format automatically based on the column type.", }, { key: "headgate", label: "Headgate", required: false, type: "TEXT_NUMBER", example: "Section 4 — North Field", description: "Display name of the headgate the reading came from.", }, { key: "measurement", label: "Measurement", required: false, type: "TEXT_NUMBER", example: "123.45", description: "The numeric reading. Sent as a string (e.g. \"123.45\"); Smartsheet's TEXT_NUMBER columns accept this and display it as a number.", }, { key: "unit", label: "Unit", required: false, type: "TEXT_NUMBER", example: "CFS", description: "Display unit, e.g. CFS, GPM, AF/Day, inches.", }, { key: "irrigator", label: "Irrigator", required: false, type: "TEXT_NUMBER", example: "Jane Doe", description: "Name of the irrigator who logged the reading.", }, { key: "notes", label: "Notes", required: false, type: "TEXT_NUMBER", example: "Replaced meter seal", description: "Free-text notes attached to the entry (may be empty).", }, ]; function SetupInstructionsModal({ onClose }: { onClose: () => void }) { return (
{ // Close when clicking the backdrop (but not the panel itself). if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}

§ 05.1 — Setup

Connect a Smartsheet

{/* Prerequisites */}

Before you start

  • You need a Smartsheet account.
  • The target sheet must exist in your account. We can add columns to it for you (see Step 2), but we cannot create the sheet itself.
  • You need admin access to the Water Log settings page (you're already here).
{/* Step 1 */}

1 Generate an API token

  1. Go to{" "} app.smartsheet.com .
  2. Click your avatar (top right) →{" "} Account →{" "} Personal Settings{" "} → API Access.
  3. Click Generate new access token. Give it a name like RouteCommerce Water Log.
  4. Copy the token immediately — Smartsheet only shows it once.
{/* Step 2 */}

2 Set up your sheet

Your sheet needs at least the two required{" "} columns below. The other five are optional but recommended — you decide which of your sheet's existing columns receives each field.

{FIELD_SCHEMA.map((f) => ( ))}
Our field Smartsheet column type Example
{f.label} {f.required && ( * )}
{f.description}
{f.type} {f.example}

* Required. The Entry ID column is what we use for dedup — without it, a re-sync would create duplicate rows.

Copy a starter header row
                
                  Entry ID,Logged At,Headgate,Measurement,Unit,Irrigator,Notes
                
              

Paste this into row 1 of a new sheet, then click the on the Entry ID cell and choose{" "} Column Type → Text/Number. Repeat for the others.

{/* Step 3 */}

3 Connect

  1. Close this dialog and paste your sheet URL{" "} or numeric sheet ID into the Sheet URL field. (File → Properties in Smartsheet shows the numeric ID.)
  2. Paste your API token into the API Access Token field.
  3. Click Test Connection — we'll load your sheet's columns into the dropdowns.
  4. Map each of our fields to one of your sheet's columns. Unmapped fields are simply skipped.
  5. Click Save Settings.

Tip: the first sync runs immediately if you set frequency to Real-time, or on the next cron tick for 15-minute / hourly. Submit a test entry from{" "} /water to see it land in your sheet.

{/* Troubleshooting */}

Common issues

  • 401 Unauthorized — the token is wrong or expired. Re-generate it in Smartsheet.
  • 404 Sheet not found — if you pasted a share URL with a slug (e.g. /sheets/abc123), Smartsheet sometimes 404s on those. Use the numeric sheet ID instead, found in{" "} File → Properties.
  • Test Connection succeeds but rows don't appear — the column type probably rejects our value (most common: a DATE column getting an ISO timestamp). Check the Recent Activity panel for the exact error.
{/* Footer */}
); }