Files
route-commerce/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx
T
RouteComm Dev ad4d3c9976 feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.

Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
  + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
  connection_enabled (master switch), last_test_*, audit columns. RLS
  brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
  time_tracking_smartsheet_config fills in only brands with no workspace
  row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
  configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).

Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
  saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
  disableSmartsheetWorkspace. Permission: can_manage_settings +
  platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
  helper. Lives outside the 'use server' file so the plaintext token
  is not exposed as an RPC surface — only callable from server-side
  sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
  read token via resolveWorkspaceToken; save* no longer accepts a
  token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
  instead of decrypting the per-feature config. 'connection_disabled'
  now skips cleanly (no retry, no failed log row) — pausing the
  workspace at the hub level no longer floods Recent Activity with
  'disabled' failures.

UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
  card. Owns the token + Test Connection + connection enabled toggle +
  default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
  token UI. Reads masked token from workspace. Sheet ID + column
  mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
  single Smartsheet section with the hub on top + both feature cards
  as siblings underneath.

Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).

Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
  server-only service module — was reachable as an RPC, would have
  returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
  of calling requireManageSettings() and discarding the return value.

Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
2026-07-03 19:18:49 -06:00

531 lines
18 KiB
TypeScript

"use client";
/**
* TimeTrackingSmartsheetCard — admin `/admin/water-log/settings`
* card for the Time Tracking → Smartsheet integration (Cycle 5).
*
* Cycle 7: this card no longer owns the API token. The token lives
* on the workspace (one per brand, owned by the hub card above).
* This card only manages the per-feature mapping: sheet ID + column
* mapping + frequency + sync enabled + recent activity.
*
* 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;
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_FREQUENCY"; value: TTSmartsheetFrequency }
| { type: "SET_SYNC_ENABLED"; value: boolean }
| { type: "SET_COLUMNS"; columns: NonNullable<State["columns"]> }
| { 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,
columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING },
loading: false,
};
}
case "SET_SHEET_ID":
return { ...state, sheetIdInput: 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: "",
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]);
// Cycle 7: Test Connection uses the workspace's saved token (no
// per-feature token to pass). The workspace action
// `testSmartsheetWorkspaceConnection` is the hub's button; here we
// call the per-feature test action with no token, which falls
// through to the workspace resolver.
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,
// No `token` — the action pulls the saved workspace token.
});
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]);
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,
// Cycle 7: token is owned by the workspace; this field is
// ignored on save but kept for type compat.
token: "",
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 (
<div className="rounded-2xl border border-[#e6dfd1] bg-white p-6">
<div className="h-5 w-40 bg-[#fdfaf2] animate-pulse rounded" />
<div className="mt-3 h-4 w-72 bg-[#fdfaf2] animate-pulse rounded" />
</div>
);
}
const hasWorkspaceToken = state.config?.hasToken === true;
const maskedToken = state.config?.maskedToken ?? null;
return (
<div
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
data-test="tt-smartsheet-card"
>
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-[#1a4d2e]">
Time Tracking Smartsheet
</h2>
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
Push every closed clock-out to a sheet inside the connected
Smartsheet workbook. The API token is managed by the hub
card above; this card only configures the sheet mapping.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#1a4d2e] accent-[#1a4d2e]"
checked={state.syncEnabled}
onChange={(e) =>
dispatch({
type: "SET_SYNC_ENABLED",
value: e.target.checked,
})
}
data-test="tt-smartsheet-enabled"
/>
Enabled
</label>
</div>
{/* Workspace token reference */}
<div className="mb-4 rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-xs text-[rgba(60,60,67,0.65)]">
{hasWorkspaceToken && maskedToken ? (
<>
Using workbook token{" "}
<span className="font-mono text-[#1a4d2e]">{maskedToken}</span>{" "}
from the hub card above.
</>
) : (
<>
No Smartsheet workbook is connected yet. Save the hub card
above with an API token before configuring this sheet.
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Sheet ID or URL
</label>
<input
type="text"
placeholder="e.g. 8309876543210123"
value={state.sheetIdInput}
onChange={(e) =>
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"
/>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
Pick the sheet inside the workbook where clock-outs should land.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Frequency
</label>
<select
value={state.frequency}
onChange={(e) =>
dispatch({
type: "SET_FREQUENCY",
value: e.target.value as TTSmartsheetFrequency,
})
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-frequency"
>
{TT_SMARTSHEET_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f.replace(/_/g, " ")}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.testing || !state.sheetIdInput}
onClick={onTestConnection}
className="rounded-md border border-[#1a4d2e] bg-white px-3 py-1.5 text-sm font-semibold text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-test"
>
{state.testing ? "Testing…" : "Test Connection"}
</button>
<button
type="button"
disabled={state.saving}
onClick={onSave}
className="rounded-md bg-[#1a4d2e] px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-[#14432a] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
{state.config?.configured && state.syncEnabled && (
<button
type="button"
disabled={state.saving}
onClick={onDisable}
className="rounded-md border border-[#a4452b] bg-white px-3 py-1.5 text-sm font-semibold text-[#a4452b] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
>
Disable
</button>
)}
<span className="ml-2 text-xs text-[rgba(60,60,67,0.55)]">
{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"}
</span>
</div>
{state.columns.length > 0 && (
<ColumnMappingPanel
columns={state.columns}
mapping={state.columnMapping}
onChange={(key, columnId) =>
dispatch({
type: "SET_COL_MAPPING_VALUE",
key,
columnId,
})
}
/>
)}
<RecentActivity recent={state.recent} queue={state.queue} />
</div>
);
}
function ColumnMappingPanel({
columns,
mapping,
onChange,
}: {
columns: NonNullable<State["columns"]>;
mapping: TTSmartsheetColumnMapping;
onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void;
}) {
return (
<div className="mt-6 rounded-xl border border-[#e6dfd1] bg-[#fdfaf2] p-4">
<h3 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h3>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.65)]">
Required: <span className="font-mono">log_id</span>,{" "}
<span className="font-mono">clock_in</span>. Others optional.
</p>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{(
[
["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]) => (
<label key={key} className="block">
<span className="block text-xs font-medium text-[rgba(60,60,67,0.65)]">
{label}
{required && (
<span className="ml-1 text-[#a4452b]">*</span>
)}
</span>
<select
value={mapping[key] ?? ""}
onChange={(e) =>
onChange(
key,
e.target.value === "" ? null : e.target.value,
)
}
className="mt-1 w-full rounded-md border border-[#e6dfd1] bg-white px-2 py-1.5 text-sm"
>
<option value=""> not mapped </option>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title || `Column ${c.id}`}
</option>
))}
</select>
</label>
))}
</div>
</div>
);
}
function RecentActivity({
recent,
queue,
}: {
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
}) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-[#1a4d2e]">
Recent activity
</h3>
<div className="mt-2 flex gap-3 text-xs text-[rgba(60,60,67,0.65)]">
<span>Pending: {queue.pending}</span>
<span>Syncing: {queue.syncing}</span>
<span>Synced: {queue.synced}</span>
<span>Failed: {queue.failed}</span>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-[#e6dfd1]">
<table className="w-full text-left text-sm">
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[rgba(60,60,67,0.55)]">
<tr>
<th className="px-3 py-2">When</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Result</th>
<th className="px-3 py-2">Error</th>
</tr>
</thead>
<tbody>
{recent.length === 0 && (
<tr>
<td
className="px-3 py-3 text-center text-[rgba(60,60,67,0.55)]"
colSpan={4}
>
No sync attempts yet.
</td>
</tr>
)}
{recent.map((r) => (
<tr key={r.id} className="border-t border-[#f0e9d8]">
<td className="px-3 py-2 text-xs">
{formatDateTime(new Date(r.createdAt))}
</td>
<td className="px-3 py-2 text-xs">{r.action}</td>
<td className="px-3 py-2 text-xs">
<span
className={
r.success
? "inline-block rounded-full bg-[#1a4d2e]/10 px-2 py-0.5 text-[#1a4d2e]"
: "inline-block rounded-full bg-[#a4452b]/10 px-2 py-0.5 text-[#a4452b]"
}
>
{r.success ? "OK" : "FAIL"}
</span>
</td>
<td className="px-3 py-2 text-xs">
{r.error ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default TimeTrackingSmartsheetCard;