"use client"; /** * TimeTrackingSmartsheetCard — admin `/admin/water-log/settings` * card for the Time Tracking → Smartsheet integration (Cycle 5). * * Slimmer than the water-log SmartsheetIntegrationCard because the * user explicitly wants this config-deferred — they have no sheet * ID yet. When they drop one in, they'll get the full column- * mapping form (Test Connection gates it). Until then, only Sheet * ID / token / frequency / sync-enabled controls are visible. * * Self-contained client component. Takes `brandId` as a prop per * the brand-isolation convention. */ import { useCallback, useEffect, useReducer, useState } from "react"; import { getTTSmartsheetConfig, saveTTSmartsheetConfig, testTTSmartsheetConnection, getTTSmartsheetRecent, disableTTSmartsheet, getTTSmartsheetQueueSummary, type TTSmartsheetConfigResponse, type TTSmartsheetRecentEntry, type TTSmartsheetQueueSummary, } from "@/actions/time-tracking/smartsheet"; import { TT_SMARTSHEET_FREQUENCIES } from "@/db/schema/time-tracking"; import type { TTSmartsheetColumnMapping, TTSmartsheetColumnKey, TTSmartsheetFrequency, } from "@/db/schema/time-tracking"; import { formatDateTime } from "@/lib/format-date"; type Props = { brandId: string; }; type Status = | { kind: "idle" } | { kind: "saved-at"; iso: string } | { kind: "error"; message: string }; type State = { config: TTSmartsheetConfigResponse | null; sheetIdInput: string; tokenInput: string; showTokenInput: boolean; frequency: TTSmartsheetFrequency; syncEnabled: boolean; columns: TTColumnOption[]; columnMapping: TTSmartsheetColumnMapping; recent: TTSmartsheetRecentEntry[]; queue: TTSmartsheetQueueSummary; loading: boolean; testing: boolean; saving: boolean; status: Status; }; type TTColumnOption = { id: string; title: string; type: string; }; type Action = | { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse } | { type: "SET_SHEET_ID"; value: string } | { type: "SET_TOKEN"; value: string } | { type: "SET_SHOW_TOKEN"; value: boolean } | { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency } | { type: "SET_SYNC_ENABLED"; value: boolean } | { type: "SET_COLUMNS"; columns: NonNullable } | { type: "SET_COL_MAPPING_VALUE"; key: TTSmartsheetColumnKey; columnId: string | null } | { type: "SET_RECENT"; entries: TTSmartsheetRecentEntry[] } | { type: "SET_QUEUE"; queue: TTSmartsheetQueueSummary } | { type: "SET_LOADING"; loading: boolean } | { type: "SET_TESTING"; value: boolean } | { type: "SET_SAVING"; value: boolean } | { type: "SET_STATUS"; value: Status }; const EMPTY_MAPPING: TTSmartsheetColumnMapping = { log_id: "", clock_in: "", clock_out: null, worker: null, task: null, hours: null, lunch_minutes: null, notes: null, }; function reducer(state: State, action: Action): State { switch (action.type) { case "SET_CONFIG": { const cfg = action.config; return { ...state, config: cfg, sheetIdInput: cfg.sheetId ?? "", frequency: cfg.syncFrequency, syncEnabled: cfg.syncEnabled, showTokenInput: !cfg.hasToken, columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING }, loading: false, }; } case "SET_SHEET_ID": return { ...state, sheetIdInput: action.value }; case "SET_TOKEN": return { ...state, tokenInput: action.value }; case "SET_SHOW_TOKEN": return { ...state, showTokenInput: action.value }; case "SET_FREQUENCY": return { ...state, frequency: action.value }; case "SET_SYNC_ENABLED": return { ...state, syncEnabled: action.value }; case "SET_COLUMNS": return { ...state, columns: action.columns }; case "SET_COL_MAPPING_VALUE": return { ...state, columnMapping: { ...state.columnMapping, [action.key]: action.columnId, }, }; case "SET_RECENT": return { ...state, recent: action.entries }; case "SET_QUEUE": return { ...state, queue: action.queue }; case "SET_LOADING": return { ...state, loading: action.loading }; case "SET_TESTING": return { ...state, testing: action.value }; case "SET_SAVING": return { ...state, saving: action.value }; case "SET_STATUS": return { ...state, status: action.value }; } } function initialState(): State { return { config: null, sheetIdInput: "", tokenInput: "", showTokenInput: true, frequency: "hourly", syncEnabled: false, columns: [], columnMapping: { ...EMPTY_MAPPING }, recent: [], queue: { pending: 0, syncing: 0, synced: 0, failed: 0 }, loading: true, testing: false, saving: false, status: { kind: "idle" }, }; } export function TimeTrackingSmartsheetCard({ brandId }: Props) { const [state, dispatch] = useReducer(reducer, undefined, initialState); const [, force] = useState({}); // for non-react subscribed re-renders const refresh = useCallback(async () => { const [cfg, recent, queue] = await Promise.all([ getTTSmartsheetConfig(brandId), getTTSmartsheetRecent(brandId, 10), getTTSmartsheetQueueSummary(brandId), ]); dispatch({ type: "SET_CONFIG", config: cfg }); dispatch({ type: "SET_RECENT", entries: recent }); dispatch({ type: "SET_QUEUE", queue }); }, [brandId]); useEffect(() => { void refresh(); }, [refresh]); const onTestConnection = useCallback(async () => { dispatch({ type: "SET_TESTING", value: true }); dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); const result = await testTTSmartsheetConnection(brandId, { sheetId: state.sheetIdInput, token: state.tokenInput || undefined, }); if (!result.success) { dispatch({ type: "SET_STATUS", value: { kind: "error", message: result.error }, }); dispatch({ type: "SET_TESTING", value: false }); return; } dispatch({ type: "SET_COLUMNS", columns: result.columns }); dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); dispatch({ type: "SET_TESTING", value: false }); force({}); }, [brandId, state.sheetIdInput, state.tokenInput]); const onSave = useCallback(async () => { dispatch({ type: "SET_SAVING", value: true }); dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); const result = await saveTTSmartsheetConfig(brandId, { sheetId: state.sheetIdInput, token: state.tokenInput, syncEnabled: state.syncEnabled, syncFrequency: state.frequency, columnMapping: state.columnMapping, }); if (!result.success) { dispatch({ type: "SET_STATUS", value: { kind: "error", message: result.error }, }); dispatch({ type: "SET_SAVING", value: false }); return; } dispatch({ type: "SET_CONFIG", config: result.config, }); dispatch({ type: "SET_STATUS", value: { kind: "saved-at", iso: new Date().toISOString() }, }); dispatch({ type: "SET_SAVING", value: false }); }, [brandId, state]); const onDisable = useCallback(async () => { dispatch({ type: "SET_SAVING", value: true }); await disableTTSmartsheet(brandId); await refresh(); dispatch({ type: "SET_SAVING", value: false }); dispatch({ type: "SET_STATUS", value: { kind: "saved-at", iso: new Date().toISOString() }, }); }, [brandId, refresh]); if (state.loading) { return (
); } return (

Smartsheet (Time Tracking)

Push every closed clock-out to a configured Smartsheet sheet. Same encryption + retry policy as the Water Log smartsheet integration.

dispatch({ type: "SET_SHEET_ID", value: e.target.value }) } className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm" data-test="tt-smartsheet-sheet-id" />
dispatch({ type: "SET_TOKEN", value: e.target.value }) } className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm" data-test="tt-smartsheet-token" />
{state.config?.hasToken && !state.tokenInput && (

A token is saved. Leave blank to keep it.

)}
{state.config?.configured && state.syncEnabled && ( )} {state.status.kind === "saved-at" ? `Saved at ${formatDateTime(new Date(state.status.iso))}` : state.status.kind === "error" ? state.status.message : state.config?.lastSyncAt ? `Last sync ${formatDateTime(new Date(state.config.lastSyncAt))}` : "Never synced"}
{state.columns.length > 0 && ( dispatch({ type: "SET_COL_MAPPING_VALUE", key, columnId, }) } /> )}
); } function ColumnMappingPanel({ columns, mapping, onChange, }: { columns: NonNullable; mapping: TTSmartsheetColumnMapping; onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void; }) { return (

Column mapping

Required: log_id,{" "} clock_in. Others optional.

{( [ ["log_id", "Log ID (UUID, for dedup)", true], ["clock_in", "Clock-in (timestamp)", true], ["clock_out", "Clock-out (timestamp)", false], ["worker", "Worker name", false], ["task", "Task name", false], ["hours", "Hours (decimal)", false], ["lunch_minutes", "Lunch minutes", false], ["notes", "Notes", false], ] as Array<[TTSmartsheetColumnKey, string, boolean]> ).map(([key, label, required]) => ( ))}
); } function RecentActivity({ recent, queue, }: { recent: TTSmartsheetRecentEntry[]; queue: TTSmartsheetQueueSummary; }) { return (

Recent activity

Pending: {queue.pending} Syncing: {queue.syncing} Synced: {queue.synced} Failed: {queue.failed}
{recent.length === 0 && ( )} {recent.map((r) => ( ))}
When Action Result Error
No sync attempts yet.
{formatDateTime(new Date(r.createdAt))} {r.action} {r.success ? "OK" : "FAIL"} {r.error ?? "—"}
); } export default TimeTrackingSmartsheetCard;