"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, useCallback } from "react"; import { getSmartsheetConfig, saveSmartsheetConfig, testSmartsheetConnection, listSmartsheetSyncLog, 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", ]; 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; }; 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[] }; 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, }; 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 }; 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]); 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 }); } } if (state.loading) { return (

Reading saved configuration.

); } const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0; return (
{state.message && (
{state.message.text}
)} dispatch({ type: "SET_ENABLED", value: v })} /> {/* Sheet */}
dispatch({ type: "SET_SHEET_ID", value: e.target.value }) } placeholder="https://app.smartsheet.com/sheets/AbCdEfGhIjKlMn or 123456789012345" className="w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]" 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="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]" 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="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]" /> )}
); }, )}
{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.

)}
); } // ── Subcomponents (mirror the parent settings page style) ────────────────── function Card({ title, subtitle, children, }: { title: string; subtitle?: string; children: React.ReactNode; }) { return (

{title}

{subtitle &&

{subtitle}

}
{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…; } if (test.state === "ok") { return (

✓ Connected to “{test.sheetName}” ({test.columnCount} columns)

); } return (

✗ {test.message}

); }