From 242c1952652eda74e4546726d806282d5dd29a9a Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 14:57:12 -0600 Subject: [PATCH] feat(water-log): add Smartsheet setup instructions modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Setup instructions' button to the Connection card that opens a modal with full onboarding guidance: - Prerequisites (Smartsheet account, admin access) - Step 1: Generate an API token (with link to Smartsheet docs) - Step 2: Sheet structure — full schema table mapping our 7 fields to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with required vs optional flags and example values - Collapsible 'starter header row' CSV snippet users can paste into row 1 of a new sheet - Step 3: Connect flow (paste URL + token, test, map columns, save) - Common issues troubleshooting (401, 404 slug URLs, DATE column rejecting ISO timestamps) Modal UX: - role='dialog', aria-modal, aria-labelledby - Closes on X button, 'Got it' footer button, click-outside backdrop, or Escape key - Sticky header + footer so scrollable content stays framed - Locked to existing cream + forest palette (Field Almanac style) Implementation: - Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL actions to reducer - Extended Card subcomponent with optional 'action' slot (used here for the trigger button, reusable for future per-card actions) - Added useEffect that registers a window keydown listener only while the modal is open; cleans up on close or unmount - FIELD_SCHEMA const declared alongside the modal so future edits to the schema live next to their documentation --- .../water-log/SmartsheetIntegrationCard.tsx | 385 +++++++++++++++++- 1 file changed, 381 insertions(+), 4 deletions(-) diff --git a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx index 92c6ddc..11311c8 100644 --- a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx +++ b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx @@ -95,6 +95,8 @@ type State = { // Recent activity recentLog: SmartsheetSyncLogEntry[]; loadingLog: boolean; + /** Whether the setup-instructions modal is open. */ + setupModalOpen: boolean; }; type Action = @@ -114,7 +116,9 @@ type Action = | { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] } | { type: "TEST_FAIL"; message: string } | { type: "CLEAR_MESSAGE" } - | { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] }; + | { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] } + | { type: "OPEN_SETUP_MODAL" } + | { type: "CLOSE_SETUP_MODAL" }; const EMPTY_MAPPING: SmartsheetColumnMapping = { entry_id: "", @@ -141,6 +145,7 @@ const initialState: State = { test: { state: "idle" }, recentLog: [], loadingLog: false, + setupModalOpen: false, }; function reducer(state: State, action: Action): State { @@ -218,6 +223,10 @@ function reducer(state: State, action: Action): State { 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 }; default: return state; } @@ -243,6 +252,18 @@ export default function SmartsheetIntegrationCard({ 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]); + async function handleSave(e: React.FormEvent) { e.preventDefault(); dispatch({ type: "SAVE_START" }); @@ -331,6 +352,15 @@ export default function SmartsheetIntegrationCard({ dispatch({ type: "OPEN_SETUP_MODAL" })} + className="rounded-md border border-[#d4d9d3] bg-white px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-[#5a5d5a] transition hover:border-[#1a4d2e] hover:text-[#1a4d2e]" + > + Setup instructions + + } > {/* Sheet */}
@@ -603,6 +633,12 @@ export default function SmartsheetIntegrationCard({ Paste an API token above to enable Save.

)} + + {state.setupModalOpen && ( + dispatch({ type: "CLOSE_SETUP_MODAL" })} + /> + )} ); } @@ -612,17 +648,22 @@ export default function SmartsheetIntegrationCard({ function Card({ title, subtitle, + action, children, }: { title: string; subtitle?: string; + action?: React.ReactNode; children: React.ReactNode; }) { return (
-
-

{title}

- {subtitle &&

{subtitle}

} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
}
{children}
@@ -687,4 +728,340 @@ function TestResultPanel({ test }: { test: TestState }) {

✗ {test.message}

); +} + +// ── 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. +
  3. + Click your avatar (top right) →{" "} + Account →{" "} + Personal Settings{" "} + → API Access. +
  4. +
  5. + Click Generate new access token. Give it a + name like RouteCommerce Water Log. +
  6. +
  7. + Copy the token immediately — Smartsheet only shows + it once. +
  8. +
+
+ + {/* 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. +
  3. + Paste your API token into the API Access Token field. +
  4. +
  5. + Click Test Connection — we'll load + your sheet's columns into the dropdowns. +
  6. +
  7. + Map each of our fields to one of your sheet's + columns. Unmapped fields are simply skipped. +
  8. +
  9. Click Save Settings.
  10. +
+

+ 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 */} + +
+
+ ); } \ No newline at end of file