fc70344dab
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.
Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
token (BYTEA), sheet id, column mapping JSONB, sync_frequency
(realtime | every_15_minutes | hourly), enable flag, last-sync
metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
tracks status / attempts / exponential backoff. UNIQUE(brand_id,
entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
(success or failure); backs the Recent Activity panel in the UI.
Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
Sequential processing stays well under Smartsheet's 300 req/min.
Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
returned in plaintext — UI gets maskedToken only. Permission check
matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
both 15-min and hourly frequencies (route filters per-brand).
UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
self-contained client component with useReducer. Sections: enable
toggle, sheet URL/ID + API token + Test Connection, sync frequency
radio group, column mapping dropdowns (populated after Test),
Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
'§ 05 — Integrations' section after the existing admin-PIN settings.
Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').
Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)
Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
AND Vercel dashboard
2. npm run migrate:one 93 (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml
Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
690 lines
23 KiB
TypeScript
690 lines
23 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, 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<keyof SmartsheetColumnMapping, string> = {
|
|
entry_id: "Entry ID (UUID, for dedup)",
|
|
logged_at: "Logged at (timestamp)",
|
|
headgate: "Headgate name",
|
|
measurement: "Measurement",
|
|
unit: "Unit (CFS, GPM, …)",
|
|
irrigator: "Irrigator name",
|
|
notes: "Notes (optional)",
|
|
};
|
|
|
|
const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
|
|
"entry_id",
|
|
"logged_at",
|
|
];
|
|
|
|
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
|
|
realtime: "Real-time (after every entry)",
|
|
every_15_minutes: "Every 15 minutes",
|
|
hourly: "Hourly",
|
|
};
|
|
|
|
type ColumnOption = { id: string; title: string; type: string };
|
|
|
|
type TestState =
|
|
| { state: "idle" }
|
|
| { state: "loading" }
|
|
| {
|
|
state: "ok";
|
|
sheetName: string;
|
|
columnCount: number;
|
|
columns: ColumnOption[];
|
|
}
|
|
| { state: "error"; message: string };
|
|
|
|
type State = {
|
|
loading: boolean;
|
|
saving: boolean;
|
|
message: { type: "success" | "error"; text: string } | null;
|
|
// 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 (
|
|
<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">
|
|
{state.message && (
|
|
<div
|
|
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
|
state.message.type === "success"
|
|
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
|
|
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
|
|
}`}
|
|
>
|
|
{state.message.text}
|
|
</div>
|
|
)}
|
|
|
|
<Card
|
|
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
|
|
title="Connection"
|
|
subtitle="Smartsheet API token + target sheet."
|
|
>
|
|
{/* 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="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"
|
|
/>
|
|
<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="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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => dispatch({ type: "TOGGLE_SHOW_TOKEN" })}
|
|
className="rounded-lg border border-[#d4d9d3] bg-white px-3 text-xs font-medium text-[#5a5d5a] hover:bg-[#f4f1e8]"
|
|
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="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-semibold text-[#1a4d2e] transition hover:bg-[#1a4d2e] hover:text-white disabled:opacity-50"
|
|
>
|
|
{state.test.state === "loading" ? "Testing…" : "Test Connection"}
|
|
</button>
|
|
<TestResultPanel test={state.test} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
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 transition ${
|
|
state.frequency === f
|
|
? "border-[#1a4d2e] bg-[#1a4d2e]/5"
|
|
: "border-[#d4d9d3] bg-white hover:border-[#1a4d2e]"
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="smartsheet-frequency"
|
|
value={f}
|
|
checked={state.frequency === f}
|
|
onChange={() =>
|
|
dispatch({ type: "SET_FREQUENCY", value: f })
|
|
}
|
|
className="mt-0.5 accent-[#1a4d2e]"
|
|
/>
|
|
<span className="flex-1 text-sm font-medium text-[#1d1d1f]">
|
|
{FREQUENCY_LABELS[f]}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
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="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
|
>
|
|
<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="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]"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
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="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
|
>
|
|
{state.saving ? "Saving…" : "Save Settings"}
|
|
</button>
|
|
|
|
{noTokenYet && (
|
|
<p className="text-center text-xs text-[#8a8b88]">
|
|
Paste an API token above to enable Save.
|
|
</p>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ── Subcomponents (mirror the parent settings page style) ──────────────────
|
|
|
|
function Card({
|
|
title,
|
|
subtitle,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
|
<header className="mb-4">
|
|
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
|
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
|
</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-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
|
|
{description && (
|
|
<p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={value}
|
|
aria-label={label}
|
|
onClick={() => onChange(!value)}
|
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
|
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
|
value ? "translate-x-6" : "translate-x-1"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestResultPanel({ test }: { test: TestState }) {
|
|
if (test.state === "idle") return null;
|
|
if (test.state === "loading") {
|
|
return <span className="text-sm text-[#5a5d5a]">Testing…</span>;
|
|
}
|
|
if (test.state === "ok") {
|
|
return (
|
|
<div className="flex-1 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-3 py-2 text-xs">
|
|
<p className="font-semibold text-[#1a4d2e]">
|
|
✓ Connected to “{test.sheetName}” ({test.columnCount} columns)
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex-1 rounded-lg border border-[#a4452b] bg-[#a4452b]/5 px-3 py-2 text-xs">
|
|
<p className="font-semibold text-[#a4452b]">✗ {test.message}</p>
|
|
</div>
|
|
);
|
|
} |