a470b6fe9d
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
The Smartsheet card has six stacked sections (Enable, Connection, Sync frequency, Column mapping, Backfill, Recent activity) — at 1100px viewport that's a long scroll. Add a sticky 'On this page' pill nav at the top of the card that highlights the active section as the user scrolls, with anchor jump on click. - Each <Card> gets a stable id (smartsheet-enable, -connection, -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so anchor jumps clear the sticky nav. - IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section is in the top 20% of the viewport and updates activeSection state. - Pill click uses native scrollIntoView, which honors the global scroll-behavior: smooth on <html> (auto-flipped to instant under prefers-reduced-motion by globals.css — no JS matchMedia needed). - URL hash updates via history.replaceState so the destination is shareable without triggering a second jump. - Apple HIG polish: rounded-full pills, focus-visible ring, active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92 with backdrop-blur for the sticky chrome. - All changes additive — no existing Card/ToggleRow behavior modified. Verified: npx tsc --noEmit clean, npm run build clean, npm run lint on this file clean.
1604 lines
59 KiB
TypeScript
1604 lines
59 KiB
TypeScript
"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<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",
|
|
];
|
|
|
|
// 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<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;
|
|
// 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<SectionId>(
|
|
"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<string>();
|
|
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
|
|
// <html> 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<HTMLAnchorElement>, 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 (
|
|
<Card title="Smartsheet Integration" subtitle="Loading…">
|
|
<p className="text-sm text-[#5a5d5a]">Reading saved configuration.</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0;
|
|
|
|
return (
|
|
<form onSubmit={handleSave} className="space-y-6">
|
|
<SectionNav activeId={activeSection} onNavigate={handleSectionClick} />
|
|
{state.message && (
|
|
<div
|
|
role={state.message.type === "error" ? "alert" : "status"}
|
|
aria-live={state.message.type === "error" ? "assertive" : "polite"}
|
|
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px]
|
|
${
|
|
state.message.type === "success"
|
|
? "border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.05] text-[#143d24]"
|
|
: "border-[#a4452b]/25 bg-[#a4452b]/[0.05] text-[#7a341f]"
|
|
}`}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white ${
|
|
state.message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
|
|
}`}
|
|
>
|
|
{state.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="font-medium leading-snug">{state.message.text}</p>
|
|
</div>
|
|
)}
|
|
|
|
<Card
|
|
id="smartsheet-enable"
|
|
title="Smartsheet Integration"
|
|
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
|
|
>
|
|
<ToggleRow
|
|
label="Enable"
|
|
description="When disabled, no new rows are pushed. Existing rows in Smartsheet are untouched."
|
|
value={state.enabled}
|
|
onChange={(v) => dispatch({ type: "SET_ENABLED", value: v })}
|
|
/>
|
|
</Card>
|
|
|
|
<Card
|
|
id="smartsheet-connection"
|
|
title="Connection"
|
|
subtitle="Smartsheet API token + target sheet."
|
|
action={
|
|
<button
|
|
type="button"
|
|
onClick={() => 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
|
|
</button>
|
|
}
|
|
>
|
|
{/* Sheet */}
|
|
<div className="space-y-1">
|
|
<label
|
|
htmlFor="smartsheet-sheet"
|
|
className="block text-xs font-medium text-[#5a5d5a]"
|
|
>
|
|
Sheet URL or sheet ID
|
|
</label>
|
|
<input
|
|
id="smartsheet-sheet"
|
|
type="text"
|
|
value={state.sheetIdInput}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
<p className="text-xs text-[#8a8b88]">
|
|
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 <em>File → Properties</em> in Smartsheet.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Token */}
|
|
<div className="mt-4 space-y-1">
|
|
<label
|
|
htmlFor="smartsheet-token"
|
|
className="block text-xs font-medium text-[#5a5d5a]"
|
|
>
|
|
API access token
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
id="smartsheet-token"
|
|
type={state.showToken ? "text" : "password"}
|
|
value={state.tokenInput}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => dispatch({ type: "TOGGLE_SHOW_TOKEN" })}
|
|
className="inline-flex min-h-[44px] items-center justify-center rounded-lg border border-[#d4d9d3] bg-white px-3
|
|
text-[13px] font-medium text-[#5a5d5a]
|
|
motion-safe:transition-colors motion-safe:duration-150
|
|
hover:bg-[#f4f1e8] hover:text-[#1d1d1f]
|
|
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/20
|
|
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out"
|
|
aria-label={state.showToken ? "Hide token" : "Show token"}
|
|
>
|
|
{state.showToken ? "Hide" : "Show"}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-[#8a8b88]">
|
|
Generate a token at{" "}
|
|
<a
|
|
href="https://app.smartsheet.com/b/home"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#1a4d2e] underline underline-offset-2"
|
|
>
|
|
app.smartsheet.com
|
|
</a>{" "}
|
|
→ <span className="font-mono">Account</span> →{" "}
|
|
<span className="font-mono">Personal Settings</span> →{" "}
|
|
<span className="font-mono">API Access</span>. The token is
|
|
encrypted with AES-256-GCM at rest.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Test Connection */}
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleTest}
|
|
disabled={state.test.state === "loading"}
|
|
className="inline-flex min-h-[40px] items-center justify-center rounded-lg border border-[#1a4d2e] bg-white px-4 py-2
|
|
text-[14px] font-semibold text-[#1a4d2e]
|
|
motion-safe:transition-[background-color,color,transform] motion-safe:duration-150 motion-safe:ease-out
|
|
hover:bg-[#1a4d2e] hover:text-white
|
|
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
|
|
active:scale-[0.97]
|
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{state.test.state === "loading" ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none" />
|
|
Testing…
|
|
</span>
|
|
) : (
|
|
"Test Connection"
|
|
)}
|
|
</button>
|
|
<TestResultPanel test={state.test} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
id="smartsheet-frequency"
|
|
title="Sync frequency"
|
|
subtitle="How often new water log entries are pushed to Smartsheet."
|
|
>
|
|
<div className="space-y-2">
|
|
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => (
|
|
<label
|
|
key={f}
|
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3
|
|
motion-safe:transition-[border-color,background-color] motion-safe:duration-150
|
|
${
|
|
state.frequency === f
|
|
? "border-[#1a4d2e] bg-[#1a4d2e]/[0.04]"
|
|
: "border-[#d4d9d3] bg-white hover:border-[#a8b4a4] hover:bg-[#fcfaf3]"
|
|
}
|
|
focus-within:ring-4 focus-within:ring-[#1a4d2e]/15 focus-within:border-[#1a4d2e]
|
|
active:scale-[0.997] motion-safe:transition-transform motion-safe:ease-out`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="smartsheet-frequency"
|
|
value={f}
|
|
checked={state.frequency === f}
|
|
onChange={() =>
|
|
dispatch({ type: "SET_FREQUENCY", value: f })
|
|
}
|
|
className="mt-0.5 h-4 w-4 cursor-pointer accent-[#1a4d2e]"
|
|
/>
|
|
<span className="flex-1 text-[14px] font-medium text-[#1d1d1f]">
|
|
{FREQUENCY_LABELS[f]}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
id="smartsheet-mapping"
|
|
title="Column mapping"
|
|
subtitle="Map our water log fields to columns in your Smartsheet. entry_id and logged_at are required."
|
|
>
|
|
{state.availableColumns.length === 0 ? (
|
|
<p className="text-xs text-[#8a8b88]">
|
|
Run <strong>Test Connection</strong> above to load the sheet's
|
|
columns. You can also paste column IDs manually below.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-[#8a8b88]">
|
|
Loaded {state.availableColumns.length} columns from sheet{" "}
|
|
<span className="font-mono">
|
|
{state.test.state === "ok" ? state.test.sheetName : "—"}
|
|
</span>
|
|
. Pick from the dropdowns below.
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{(Object.keys(COLUMN_LABELS) as Array<keyof SmartsheetColumnMapping>).map(
|
|
(key) => {
|
|
const required = REQUIRED_COLUMNS.includes(key);
|
|
const value = state.mapping[key];
|
|
const valueAsString = value ?? "";
|
|
return (
|
|
<div key={key} className="flex items-center gap-3">
|
|
<label
|
|
htmlFor={`map-${key}`}
|
|
className="w-1/3 text-sm font-medium text-[#1d1d1f]"
|
|
>
|
|
{COLUMN_LABELS[key]}
|
|
{required && (
|
|
<span className="ml-1 text-xs text-[#a4452b]">
|
|
required
|
|
</span>
|
|
)}
|
|
</label>
|
|
{state.availableColumns.length > 0 ? (
|
|
<select
|
|
id={`map-${key}`}
|
|
value={valueAsString}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_MAPPING_KEY",
|
|
key,
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
className="min-h-[40px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2
|
|
text-[14px] text-[#1d1d1f]
|
|
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
|
|
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
|
|
>
|
|
<option value="">
|
|
— {required ? "Select a column" : "Not mapped"} —
|
|
</option>
|
|
{state.availableColumns.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.title}
|
|
{c.type !== "TEXT_NUMBER"
|
|
? ` (${c.type.toLowerCase()})`
|
|
: ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
id={`map-${key}`}
|
|
type="text"
|
|
value={valueAsString}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<BackfillCard
|
|
id="smartsheet-backfill"
|
|
state={state.backfill}
|
|
onRun={handleBackfill}
|
|
onRangeChange={(v) => dispatch({ type: "BACKFILL_RANGE", value: v })}
|
|
/>
|
|
|
|
<Card
|
|
id="smartsheet-activity"
|
|
title="Recent activity"
|
|
subtitle="Last 10 sync attempts. Use this to spot token / sheet issues quickly."
|
|
>
|
|
{state.loadingLog ? (
|
|
<p className="text-sm text-[#5a5d5a]">Loading…</p>
|
|
) : state.recentLog.length === 0 ? (
|
|
<p className="text-sm text-[#5a5d5a]">
|
|
No sync attempts yet. Save the config and submit a water log
|
|
entry from <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water</code>.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-[#d4d9d3]">
|
|
{state.recentLog.map((log) => (
|
|
<li
|
|
key={log.id}
|
|
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
|
>
|
|
<span
|
|
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ${
|
|
log.success ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
|
|
}`}
|
|
aria-label={log.success ? "success" : "failure"}
|
|
>
|
|
{log.success ? "✓" : "✗"}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
|
|
<span className="font-mono font-semibold text-[#1d1d1f]">
|
|
{log.action}
|
|
</span>
|
|
<span className="text-[#5a5d5a]">
|
|
{formatDateTime(log.createdAt)}
|
|
</span>
|
|
{log.durationMs != null && (
|
|
<span className="text-[#8a8b88]">
|
|
{log.durationMs}ms
|
|
</span>
|
|
)}
|
|
</div>
|
|
{log.error && (
|
|
<p className="mt-0.5 text-xs text-[#a4452b]">{log.error}</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={state.saving || noTokenYet}
|
|
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 disabled:hover:bg-[#1a4d2e]"
|
|
>
|
|
{state.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>
|
|
|
|
{noTokenYet && (
|
|
<p className="text-center text-xs text-[#8a8b88]">
|
|
Paste an API token above to enable Save.
|
|
</p>
|
|
)}
|
|
|
|
{state.setupModalOpen && (
|
|
<SetupInstructionsModal
|
|
onClose={() => dispatch({ type: "CLOSE_SETUP_MODAL" })}
|
|
/>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ── Subcomponents (mirror the parent settings page style) ──────────────────
|
|
|
|
function SectionNav({
|
|
activeId,
|
|
onNavigate,
|
|
}: {
|
|
activeId: string;
|
|
onNavigate: (e: React.MouseEvent<HTMLAnchorElement>, 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 (
|
|
<nav
|
|
aria-label="On this page"
|
|
className="smartsheet-fade-in sticky top-2 z-20 -mx-1 flex flex-wrap items-center gap-1 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2]/92 px-3 py-2 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] backdrop-blur-md"
|
|
>
|
|
<span className="mr-1 hidden text-[10px] font-mono uppercase tracking-[0.22em] text-[#8a6b3b] sm:inline">
|
|
On this page
|
|
</span>
|
|
<ul className="flex flex-wrap items-center gap-1">
|
|
{sections.map((s) => {
|
|
const active = activeId === s.id;
|
|
return (
|
|
<li key={s.id}>
|
|
<a
|
|
href={`#${s.id}`}
|
|
aria-current={active ? "true" : undefined}
|
|
onClick={(e) => onNavigate(e, s.id)}
|
|
className={`inline-flex min-h-[28px] items-center justify-center rounded-full px-2.5 text-[12px] font-semibold tracking-tight
|
|
motion-safe:transition-[background-color,color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
|
|
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
|
|
active:scale-[0.96]
|
|
${
|
|
active
|
|
? "bg-[#1a4d2e] text-white shadow-[0_1px_2px_rgba(15,23,18,0.1)]"
|
|
: "text-[#5a5d5a] hover:bg-[#f0ebde] hover:text-[#1d1d1f]"
|
|
}`}
|
|
>
|
|
{s.label}
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function Card({
|
|
id,
|
|
title,
|
|
subtitle,
|
|
action,
|
|
children,
|
|
}: {
|
|
id?: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
action?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<section
|
|
id={id}
|
|
// `scroll-mt-20` keeps anchor jumps clear of the sticky "On this page" nav.
|
|
className="scroll-mt-20 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)] motion-safe:transition-shadow"
|
|
>
|
|
<header className="mb-5 flex items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
)}
|
|
</div>
|
|
{action && <div className="shrink-0">{action}</div>}
|
|
</header>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
label,
|
|
description,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
value: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-4 py-1">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[14px] font-medium leading-snug text-[#1d1d1f]">
|
|
{label}
|
|
</p>
|
|
{description && (
|
|
<p className="mt-0.5 text-[12.5px] leading-snug text-[#5a5d5a]">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={value}
|
|
aria-label={label}
|
|
onClick={() => onChange(!value)}
|
|
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-[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>
|
|
);
|
|
}
|
|
|
|
function TestResultPanel({ test }: { test: TestState }) {
|
|
if (test.state === "idle") return null;
|
|
if (test.state === "loading") {
|
|
return (
|
|
<span className="inline-flex items-center gap-2 text-[13px] text-[#5a5d5a]">
|
|
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-[#d4d9d3] border-t-[#1a4d2e] motion-reduce:animate-none" />
|
|
Testing connection…
|
|
</span>
|
|
);
|
|
}
|
|
if (test.state === "ok") {
|
|
return (
|
|
<div className="flex flex-1 items-start gap-2 rounded-lg border border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.04] px-3 py-2 text-[13px]">
|
|
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-white">
|
|
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden>
|
|
<path
|
|
d="M2 6.5L5 9.5L10 3.5"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
fill="none"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<p className="text-[#1a4d2e]">
|
|
Connected to <span className="font-semibold">“{test.sheetName}”</span>{" "}
|
|
<span className="text-[#3a6649]">— {test.columnCount} columns loaded</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex flex-1 items-start gap-2 rounded-lg border border-[#a4452b]/25 bg-[#a4452b]/[0.04] px-3 py-2 text-[13px]">
|
|
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#a4452b] text-white">
|
|
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden>
|
|
<path
|
|
d="M3 3L9 9M9 3L3 9"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<p className="text-[#a4452b]">{test.message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
id={id}
|
|
title="Backfill historical entries"
|
|
subtitle="Push existing water log entries to your Smartsheet. Safe to re-run — already-pushed entries are deduped by Entry ID."
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<label
|
|
htmlFor="smartsheet-backfill-range"
|
|
className="text-sm font-medium text-[#1d1d1f]"
|
|
>
|
|
Range
|
|
</label>
|
|
<select
|
|
id="smartsheet-backfill-range"
|
|
value={state.range}
|
|
onChange={(e) =>
|
|
onRangeChange(e.target.value as "all" | "30d" | "7d")
|
|
}
|
|
className="min-h-[40px] rounded-lg border border-[#d4d9d3] bg-white px-3 py-2
|
|
text-[14px] text-[#1d1d1f]
|
|
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
|
|
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15
|
|
disabled:opacity-50"
|
|
disabled={state.running}
|
|
>
|
|
<option value="all">All time</option>
|
|
<option value="30d">Last 30 days</option>
|
|
<option value="7d">Last 7 days</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={onRun}
|
|
disabled={state.running}
|
|
className="inline-flex min-h-[40px] items-center justify-center rounded-lg border border-[#1a4d2e] bg-white px-4 py-2
|
|
text-[14px] font-semibold text-[#1a4d2e]
|
|
motion-safe:transition-[background-color,color,transform] motion-safe:duration-150 motion-safe:ease-out
|
|
hover:bg-[#1a4d2e] hover:text-white
|
|
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
|
|
active:scale-[0.97]
|
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{state.running ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none" />
|
|
Running backfill…
|
|
</span>
|
|
) : (
|
|
"Run backfill"
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{state.running && (
|
|
<p className="text-xs text-[#5a5d5a]">
|
|
Counting entries, enqueueing, and draining one batch inline. Any
|
|
remaining entries will be drained by the cron (every 15 minutes).
|
|
</p>
|
|
)}
|
|
|
|
{showError && (
|
|
<div
|
|
role="alert"
|
|
aria-live="assertive"
|
|
className="flex items-start gap-2.5 rounded-lg border border-[#a4452b]/25 bg-[#a4452b]/[0.05] px-3 py-2.5 text-[13px]"
|
|
>
|
|
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#a4452b] text-white">
|
|
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5">
|
|
<path
|
|
d="M3 3L9 9M9 3L3 9"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<p className="font-medium leading-snug text-[#7a341f]">
|
|
{result.error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{showResult && result && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="flex items-start gap-2.5 rounded-lg border border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.04] px-3 py-2.5 text-[13px]"
|
|
>
|
|
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-white">
|
|
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5">
|
|
<path
|
|
d="M2 6.5L5 9.5L10 3.5"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
fill="none"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<div className="text-[#143d24]">
|
|
<p className="font-medium leading-snug">
|
|
Found {result.considered}{" "}
|
|
{result.considered === 1 ? "entry" : "entries"} matching the
|
|
range
|
|
{result.considered > 0 && (
|
|
<>
|
|
{" "}
|
|
({result.newlyQueued} new, {result.alreadyQueued} already
|
|
queued).
|
|
</>
|
|
)}
|
|
</p>
|
|
{(result.drained.synced +
|
|
result.drained.skipped +
|
|
result.drained.failed >
|
|
0 || result.drained.remaining > 0) && (
|
|
<p className="mt-1 leading-snug text-[#1a4d2e]/85">
|
|
Inline drain: {result.drained.synced} synced /{" "}
|
|
{result.drained.skipped} skipped / {result.drained.failed}{" "}
|
|
failed.
|
|
{result.drained.remaining > 0 && (
|
|
<>
|
|
{" "}
|
|
<strong className="font-semibold text-[#1a4d2e]">
|
|
{result.drained.remaining} still queued
|
|
</strong>{" "}
|
|
— they'll drain on the next cron tick (within 15
|
|
minutes).
|
|
</>
|
|
)}
|
|
{result.drained.remaining === 0 &&
|
|
result.considered > 0 && (
|
|
<> All entries are now in Smartsheet.</>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div
|
|
className="smartsheet-fade-in fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 backdrop-blur-[6px]
|
|
sm:items-center sm:p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="smartsheet-setup-title"
|
|
onClick={(e) => {
|
|
// Close when clicking the backdrop (but not the panel itself).
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
className="relative max-h-[92vh] w-full max-w-2xl overflow-y-auto rounded-t-2xl border border-[#d4d9d3]
|
|
bg-[#fdfaf2] shadow-[0_-12px_40px_-12px_rgba(15,23,18,0.2),0_24px_60px_-20px_rgba(15,23,18,0.25)]
|
|
smartsheet-sheet-up
|
|
sm:rounded-2xl"
|
|
>
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3]/80 bg-[#fdfaf2]/95 px-6 py-4 backdrop-blur-sm">
|
|
<div>
|
|
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
|
§ 05.1 — Setup
|
|
</p>
|
|
<h2
|
|
id="smartsheet-setup-title"
|
|
className="mt-1 text-[22px] font-medium leading-tight tracking-tight text-[#1a4d2e]"
|
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
|
>
|
|
Connect a Smartsheet
|
|
</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="-mr-2 -mt-1 inline-flex h-9 w-9 items-center justify-center rounded-full
|
|
text-[#5a5d5a]
|
|
motion-safe:transition-colors motion-safe:duration-150
|
|
hover:bg-[#f0ebde] hover:text-[#1d1d1f]
|
|
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
|
|
active:scale-95 motion-safe:transition-transform motion-safe:ease-out"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
className="h-5 w-5"
|
|
aria-hidden
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="space-y-6 px-6 py-5">
|
|
{/* Prerequisites */}
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-[#1d1d1f]">
|
|
Before you start
|
|
</h3>
|
|
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-[#5a5d5a]">
|
|
<li>You need a Smartsheet account.</li>
|
|
<li>
|
|
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.
|
|
</li>
|
|
<li>
|
|
You need admin access to the Water Log settings page
|
|
(you're already here).
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
{/* Step 1 */}
|
|
<section>
|
|
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
|
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
|
1
|
|
</span>
|
|
Generate an API token
|
|
</h3>
|
|
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
|
|
<li>
|
|
Go to{" "}
|
|
<a
|
|
href="https://app.smartsheet.com/b/home"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#1a4d2e] underline underline-offset-2"
|
|
>
|
|
app.smartsheet.com
|
|
</a>
|
|
.
|
|
</li>
|
|
<li>
|
|
Click your avatar (top right) →{" "}
|
|
<span className="font-mono text-xs">Account</span> →{" "}
|
|
<span className="font-mono text-xs">Personal Settings</span>{" "}
|
|
→ <span className="font-mono text-xs">API Access</span>.
|
|
</li>
|
|
<li>
|
|
Click <strong>Generate new access token</strong>. Give it a
|
|
name like <span className="font-mono text-xs">RouteCommerce Water Log</span>.
|
|
</li>
|
|
<li>
|
|
Copy the token <em>immediately</em> — Smartsheet only shows
|
|
it once.
|
|
</li>
|
|
</ol>
|
|
</section>
|
|
|
|
{/* Step 2 */}
|
|
<section>
|
|
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
|
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
|
2
|
|
</span>
|
|
Set up your sheet
|
|
</h3>
|
|
<p className="mt-2 text-sm text-[#5a5d5a]">
|
|
Your sheet needs at least the two <strong>required</strong>{" "}
|
|
columns below. The other five are optional but recommended —
|
|
you decide which of your sheet's existing columns
|
|
receives each field.
|
|
</p>
|
|
|
|
<div className="mt-3 overflow-hidden rounded-lg border border-[#d4d9d3]">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-[#f4f1e8] text-[#5a5d5a]">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-semibold">
|
|
Our field
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-semibold">
|
|
Smartsheet column type
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-semibold">
|
|
Example
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#d4d9d3] bg-white">
|
|
{FIELD_SCHEMA.map((f) => (
|
|
<tr key={f.key}>
|
|
<td className="px-3 py-2 align-top">
|
|
<div className="font-semibold text-[#1d1d1f]">
|
|
{f.label}
|
|
{f.required && (
|
|
<span className="ml-1 text-[#a4452b]">*</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 text-[10px] text-[#8a8b88]">
|
|
{f.description}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
|
|
{f.type}
|
|
</td>
|
|
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
|
|
{f.example}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="mt-2 text-xs text-[#8a8b88]">
|
|
<span className="text-[#a4452b]">*</span> Required. The
|
|
Entry ID column is what we use for dedup — without it, a
|
|
re-sync would create duplicate rows.
|
|
</p>
|
|
|
|
<details className="mt-3 rounded-lg border border-[#d4d9d3] bg-white">
|
|
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#f4f1e8]">
|
|
Copy a starter header row
|
|
</summary>
|
|
<pre className="overflow-x-auto border-t border-[#d4d9d3] bg-[#fdfaf2] px-4 py-3 text-xs text-[#1d1d1f]">
|
|
<code>
|
|
Entry ID,Logged At,Headgate,Measurement,Unit,Irrigator,Notes
|
|
</code>
|
|
</pre>
|
|
<p className="border-t border-[#d4d9d3] px-4 py-2 text-xs text-[#5a5d5a]">
|
|
Paste this into row 1 of a new sheet, then click the
|
|
<span className="font-mono"> ▼ </span>
|
|
on the Entry ID cell and choose{" "}
|
|
<span className="font-mono">Column Type → Text/Number</span>.
|
|
Repeat for the others.
|
|
</p>
|
|
</details>
|
|
</section>
|
|
|
|
{/* Step 3 */}
|
|
<section>
|
|
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
|
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
|
3
|
|
</span>
|
|
Connect
|
|
</h3>
|
|
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
|
|
<li>
|
|
Close this dialog and paste your <strong>sheet URL</strong>{" "}
|
|
or numeric sheet ID into the Sheet URL field. (File →
|
|
Properties in Smartsheet shows the numeric ID.)
|
|
</li>
|
|
<li>
|
|
Paste your API token into the API Access Token field.
|
|
</li>
|
|
<li>
|
|
Click <strong>Test Connection</strong> — we'll load
|
|
your sheet's columns into the dropdowns.
|
|
</li>
|
|
<li>
|
|
Map each of our fields to one of your sheet's
|
|
columns. Unmapped fields are simply skipped.
|
|
</li>
|
|
<li>Click <strong>Save Settings</strong>.</li>
|
|
</ol>
|
|
<p className="mt-3 rounded-lg border border-[#1a4d2e]/30 bg-[#1a4d2e]/5 px-3 py-2 text-xs text-[#1a4d2e]">
|
|
<strong>Tip:</strong> the first sync runs immediately if you
|
|
set frequency to <em>Real-time</em>, or on the next cron
|
|
tick for 15-minute / hourly. Submit a test entry from{" "}
|
|
<code className="font-mono">/water</code> to see it land in
|
|
your sheet.
|
|
</p>
|
|
</section>
|
|
|
|
{/* Troubleshooting */}
|
|
<section className="border-t border-[#d4d9d3] pt-4">
|
|
<h3 className="text-sm font-semibold text-[#1d1d1f]">
|
|
Common issues
|
|
</h3>
|
|
<ul className="mt-2 space-y-2 text-xs text-[#5a5d5a]">
|
|
<li>
|
|
<strong>401 Unauthorized</strong> — the token is wrong or
|
|
expired. Re-generate it in Smartsheet.
|
|
</li>
|
|
<li>
|
|
<strong>404 Sheet not found</strong> — if you pasted a
|
|
share URL with a slug (e.g. <code className="font-mono">/sheets/abc123</code>),
|
|
Smartsheet sometimes 404s on those. Use the numeric sheet
|
|
ID instead, found in{" "}
|
|
<span className="font-mono">File → Properties</span>.
|
|
</li>
|
|
<li>
|
|
<strong>Test Connection succeeds but rows don't
|
|
appear</strong> — 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.
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<footer className="sticky bottom-0 flex justify-end gap-2 border-t border-[#d4d9d3]/80 bg-[#fdfaf2]/95 px-6 py-3 backdrop-blur-sm">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="inline-flex min-h-[40px] items-center justify-center rounded-lg bg-[#1a4d2e] px-5
|
|
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] 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]"
|
|
>
|
|
Got it
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |