ad4d3c9976
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).
589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* /admin/water-log/settings
|
||
*
|
||
* Site-admin-facing settings page for the Water Log module. Controls:
|
||
* - Whether `/water/admin` is enabled
|
||
* - 4-digit admin PIN (regenerate on demand)
|
||
* - Session duration (hours)
|
||
* - Per-coarse-permission flags (edit / delete / export)
|
||
* - High/low alert phone + enable flag
|
||
*
|
||
* Visual language: Field Almanac. Same cream + forest palette as
|
||
* the main WaterLogAdminPanel, but trimmed to a single column for
|
||
* a focused "form" feel.
|
||
*/
|
||
|
||
import { useReducer, useEffect, useCallback } from "react";
|
||
import {
|
||
getWaterAdminSettings,
|
||
saveWaterAdminSettings,
|
||
regenerateAdminPin,
|
||
type AdminSettings,
|
||
} from "@/actions/water-log/settings";
|
||
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
|
||
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
|
||
import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard";
|
||
|
||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||
|
||
type Message = { type: "success" | "error"; text: string } | null;
|
||
|
||
type State = {
|
||
settings: AdminSettings | null;
|
||
loading: boolean;
|
||
saving: boolean;
|
||
regenerating: boolean;
|
||
revealedPin: string | null;
|
||
message: Message;
|
||
enabled: boolean;
|
||
sessionDuration: number;
|
||
canEdit: boolean;
|
||
canDelete: boolean;
|
||
canExport: boolean;
|
||
alertPhone: string;
|
||
alertsEnabled: boolean;
|
||
};
|
||
|
||
type Action =
|
||
| { type: "LOAD_START" }
|
||
| {
|
||
type: "LOAD_SUCCESS";
|
||
data: AdminSettings;
|
||
}
|
||
| { type: "LOAD_DONE" }
|
||
| { type: "SAVE_START" }
|
||
| { type: "SAVE_SUCCESS"; settings: AdminSettings | undefined }
|
||
| { type: "SAVE_FAIL"; message: Message }
|
||
| { type: "SAVE_DONE" }
|
||
| { type: "REGEN_START" }
|
||
| { type: "REGEN_SUCCESS"; pin: string; message: Message }
|
||
| { type: "REGEN_FAIL"; message: Message }
|
||
| { type: "REGEN_DONE" }
|
||
| { type: "DISMISS_PIN" }
|
||
| { type: "CLEAR_MESSAGE" }
|
||
| { type: "SET_ENABLED"; value: boolean }
|
||
| { type: "SET_SESSION_DURATION"; value: number }
|
||
| { type: "SET_CAN_EDIT"; value: boolean }
|
||
| { type: "SET_CAN_DELETE"; value: boolean }
|
||
| { type: "SET_CAN_EXPORT"; value: boolean }
|
||
| { type: "SET_ALERT_PHONE"; value: string }
|
||
| { type: "SET_ALERTS_ENABLED"; value: boolean };
|
||
|
||
const initialState: State = {
|
||
settings: null,
|
||
loading: true,
|
||
saving: false,
|
||
regenerating: false,
|
||
revealedPin: null,
|
||
message: null,
|
||
enabled: false,
|
||
sessionDuration: 12,
|
||
canEdit: true,
|
||
canDelete: true,
|
||
canExport: true,
|
||
alertPhone: "",
|
||
alertsEnabled: false,
|
||
};
|
||
|
||
function reducer(state: State, action: Action): State {
|
||
switch (action.type) {
|
||
case "LOAD_START":
|
||
return { ...state, loading: true };
|
||
case "LOAD_SUCCESS":
|
||
return {
|
||
...state,
|
||
settings: action.data,
|
||
enabled: action.data.enabled,
|
||
sessionDuration: action.data.sessionDurationHours,
|
||
canEdit: action.data.canEditEntries,
|
||
canDelete: action.data.canDeleteEntries,
|
||
canExport: action.data.canExportCsv,
|
||
alertPhone: action.data.alertPhone ?? "",
|
||
alertsEnabled: action.data.alertsEnabled,
|
||
};
|
||
case "LOAD_DONE":
|
||
return { ...state, loading: false };
|
||
case "SAVE_START":
|
||
return { ...state, saving: true, message: null };
|
||
case "SAVE_SUCCESS":
|
||
return {
|
||
...state,
|
||
message: { type: "success", text: "Settings saved" },
|
||
settings: action.settings ?? state.settings,
|
||
};
|
||
case "SAVE_FAIL":
|
||
return { ...state, message: action.message };
|
||
case "SAVE_DONE":
|
||
return { ...state, saving: false };
|
||
case "REGEN_START":
|
||
return { ...state, regenerating: true, message: null };
|
||
case "REGEN_SUCCESS":
|
||
return {
|
||
...state,
|
||
revealedPin: action.pin,
|
||
message: action.message,
|
||
};
|
||
case "REGEN_FAIL":
|
||
return { ...state, message: action.message };
|
||
case "REGEN_DONE":
|
||
return { ...state, regenerating: false };
|
||
case "DISMISS_PIN":
|
||
return { ...state, revealedPin: null };
|
||
case "CLEAR_MESSAGE":
|
||
return { ...state, message: null };
|
||
case "SET_ENABLED":
|
||
return { ...state, enabled: action.value };
|
||
case "SET_SESSION_DURATION":
|
||
return { ...state, sessionDuration: action.value };
|
||
case "SET_CAN_EDIT":
|
||
return { ...state, canEdit: action.value };
|
||
case "SET_CAN_DELETE":
|
||
return { ...state, canDelete: action.value };
|
||
case "SET_CAN_EXPORT":
|
||
return { ...state, canExport: action.value };
|
||
case "SET_ALERT_PHONE":
|
||
return { ...state, alertPhone: action.value };
|
||
case "SET_ALERTS_ENABLED":
|
||
return { ...state, alertsEnabled: action.value };
|
||
default:
|
||
return state;
|
||
}
|
||
}
|
||
|
||
export default function WaterLogSettingsPage() {
|
||
const [state, dispatch] = useReducer(reducer, initialState);
|
||
const {
|
||
settings,
|
||
loading,
|
||
saving,
|
||
regenerating,
|
||
revealedPin,
|
||
message,
|
||
enabled,
|
||
sessionDuration,
|
||
canEdit,
|
||
canDelete,
|
||
canExport,
|
||
alertPhone,
|
||
alertsEnabled,
|
||
} = state;
|
||
|
||
const loadSettings = useCallback(async () => {
|
||
dispatch({ type: "LOAD_START" });
|
||
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
|
||
if (data) {
|
||
dispatch({ type: "LOAD_SUCCESS", data });
|
||
}
|
||
dispatch({ type: "LOAD_DONE" });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Load on mount — setState-in-effect is intentional here
|
||
// because we're hydrating from server data.
|
||
void loadSettings();
|
||
}, [loadSettings]);
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
dispatch({ type: "SAVE_START" });
|
||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||
enabled,
|
||
sessionDurationHours: sessionDuration,
|
||
canEditEntries: canEdit,
|
||
canDeleteEntries: canDelete,
|
||
canExportCsv: canExport,
|
||
alertPhone: alertPhone || null,
|
||
alertsEnabled,
|
||
});
|
||
if (result.success) {
|
||
dispatch({ type: "SAVE_SUCCESS", settings: result.settings });
|
||
} else {
|
||
dispatch({
|
||
type: "SAVE_FAIL",
|
||
message: { type: "error", text: result.error ?? "Save failed" },
|
||
});
|
||
}
|
||
dispatch({ type: "SAVE_DONE" });
|
||
}
|
||
|
||
async function handleRegenerate() {
|
||
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
|
||
return;
|
||
}
|
||
dispatch({ type: "REGEN_START" });
|
||
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
|
||
if (result.success && result.pin) {
|
||
dispatch({
|
||
type: "REGEN_SUCCESS",
|
||
pin: result.pin,
|
||
message: {
|
||
type: "success",
|
||
text: "New PIN generated. Save it now — it will not be shown again.",
|
||
},
|
||
});
|
||
} else {
|
||
dispatch({
|
||
type: "REGEN_FAIL",
|
||
message: { type: "error", text: result.error ?? "Failed to regenerate PIN" },
|
||
});
|
||
}
|
||
dispatch({ type: "REGEN_DONE" });
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
|
||
<span className="text-[#5a5d5a]">Loading…</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
|
||
{/* Header */}
|
||
<div className="border-b border-[#d4d9d3] bg-white">
|
||
<div className="mx-auto max-w-2xl px-6 py-6">
|
||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 — Settings</p>
|
||
<h1
|
||
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
|
||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||
>
|
||
Water Log · Admin Portal
|
||
</h1>
|
||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
|
||
This is separate from the platform admin login.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mx-auto max-w-2xl px-6 py-8">
|
||
<form onSubmit={handleSave} className="space-y-6">
|
||
{message && (
|
||
<div
|
||
role={message.type === "error" ? "alert" : "status"}
|
||
aria-live={message.type === "error" ? "assertive" : "polite"}
|
||
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px] font-medium
|
||
${
|
||
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 ${
|
||
message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
|
||
}`}
|
||
>
|
||
{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="leading-snug">{message.text}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Enable toggle */}
|
||
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
|
||
<ToggleRow
|
||
label="Enable"
|
||
description="Allow water admins to sign in with a 4-digit PIN."
|
||
value={enabled}
|
||
onChange={(value) => dispatch({ type: "SET_ENABLED", value })}
|
||
/>
|
||
</Card>
|
||
|
||
{enabled && (
|
||
<>
|
||
{/* PIN */}
|
||
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
|
||
{revealedPin ? (
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
|
||
New PIN — write it down
|
||
</p>
|
||
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
|
||
<span
|
||
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
|
||
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
|
||
>
|
||
{revealedPin}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => dispatch({ type: "DISMISS_PIN" })}
|
||
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
|
||
>
|
||
I've saved it — dismiss
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-sm text-[#5a5d5a]">
|
||
{settings?.pin === null
|
||
? "Generate a new PIN to give to your water admin."
|
||
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={handleRegenerate}
|
||
disabled={regenerating}
|
||
className="inline-flex min-h-[40px] shrink-0 items-center justify-center rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2
|
||
text-[14px] font-semibold text-white
|
||
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]
|
||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{regenerating ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
|
||
Generating…
|
||
</span>
|
||
) : (
|
||
"Regenerate PIN"
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Session Duration */}
|
||
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1–168 hours)">
|
||
<div className="flex items-center gap-4">
|
||
<input
|
||
type="range"
|
||
min={1}
|
||
max={168}
|
||
value={sessionDuration}
|
||
onChange={(e) =>
|
||
dispatch({
|
||
type: "SET_SESSION_DURATION",
|
||
value: parseInt(e.target.value, 10),
|
||
})
|
||
}
|
||
className="flex-1 accent-[#1a4d2e]"
|
||
aria-label="Session duration in hours"
|
||
/>
|
||
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
|
||
{sessionDuration}h
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Permissions */}
|
||
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
|
||
<div className="divide-y divide-[#d4d9d3]">
|
||
<ToggleRow
|
||
label="Edit entries"
|
||
description="Allow modifying existing log entries."
|
||
value={canEdit}
|
||
onChange={(value) => dispatch({ type: "SET_CAN_EDIT", value })}
|
||
/>
|
||
<ToggleRow
|
||
label="Delete entries"
|
||
description="Allow removing log entries."
|
||
value={canDelete}
|
||
onChange={(value) => dispatch({ type: "SET_CAN_DELETE", value })}
|
||
/>
|
||
<ToggleRow
|
||
label="Export CSV"
|
||
description="Allow exporting water log data."
|
||
value={canExport}
|
||
onChange={(value) => dispatch({ type: "SET_CAN_EXPORT", value })}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* High/Low Alerts */}
|
||
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
|
||
<ToggleRow
|
||
label="Enable alerts"
|
||
description="Threshold breach notifications."
|
||
value={alertsEnabled}
|
||
onChange={(value) => dispatch({ type: "SET_ALERTS_ENABLED", value })}
|
||
/>
|
||
{alertsEnabled && (
|
||
<div className="mt-4">
|
||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
|
||
Alert phone number
|
||
</label>
|
||
<input aria-label="+13035551234"
|
||
id="water-alert-phone"
|
||
type="tel"
|
||
value={alertPhone}
|
||
onChange={(e) =>
|
||
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
|
||
}
|
||
placeholder="+13035551234"
|
||
className="mt-1 block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
|
||
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"
|
||
autoComplete="tel"
|
||
/>
|
||
<p className="mt-1 text-xs text-[#8a8b88]">
|
||
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
|
||
</p>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
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"
|
||
>
|
||
{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>
|
||
</form>
|
||
|
||
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
|
||
The hub card owns the brand-level workbook connection (token +
|
||
test + enable); the per-feature cards below pick which sheet
|
||
inside the workbook to write to. */}
|
||
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
|
||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
||
§ 05 — Integrations
|
||
</p>
|
||
<h2
|
||
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
|
||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||
>
|
||
Smartsheet
|
||
</h2>
|
||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||
Connect one Smartsheet workbook and pick which sheet each
|
||
feature writes to. The token is encrypted at rest; per-feature
|
||
cards below manage only their sheet mapping.
|
||
</p>
|
||
<div className="mt-6">
|
||
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Water Log sheet mapping — uses the workbook token from the
|
||
hub card above. Configures which sheet inside the workbook
|
||
receives water-log entries. */}
|
||
<div className="mt-8">
|
||
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
|
||
</div>
|
||
|
||
{/* Time Tracking sheet mapping — also uses the workbook token.
|
||
Configures which sheet inside the workbook receives
|
||
clock-out rows. */}
|
||
<div className="mt-8">
|
||
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
|
||
</div>
|
||
|
||
{/* § 06 removed in Cycle 7 — Time Tracking sheet mapping is now
|
||
a sibling card under § 05 above (uses the workbook token). */}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Card({
|
||
title,
|
||
subtitle,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<section className="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)]">
|
||
<header className="mb-5">
|
||
<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>
|
||
)}
|
||
</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 py-3 first:pt-0 last:pb-0">
|
||
<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={`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>
|
||
);
|
||
} |