feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 + src/services/smartsheet-sync.ts). End-to-end wiring is in place; the brand fills in sheet ID + token + column mapping whenever ready. DB: - 0095_time_tracking_smartsheet_sync.sql — config + sync_queue + sync_log tables with brand-scoped RLS. Unique constraint on (brand_id, log_id) keeps the queue idempotent against retries. Code: - db/schema/time-tracking.ts — adds TTSmartsheetFrequency, TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey, TTSmartsheetColumnMapping, and the three pgTable defs. - src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet (dedup-by-log_id, token-echo sanitization, retry-with-backoff), drainTTSyncQueue, runScheduledTTSync. - src/actions/time-tracking/smartsheet.ts — full admin CRUD (getTTSmartsheetConfig, saveTTSmartsheetConfig, testTTSmartsheetConnection, getTTSmartsheetRecent, getTTSmartsheetQueueSummary, disableTTSmartsheet). - src/actions/time-tracking/field.ts — clockOutWorker calls the new enqueueClockOutForSync helper in its OWN transaction (so a queue- insert failure can never poison the clock-out commit; reviewer caught this in cycle 2's same-style bug pattern). - src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx — slim, focused card: sheet ID + token + frequency + enable + Test Connection → column-mapping dropdowns + Recent Activity table. - src/app/admin/water-log/settings/page.tsx — §06 section renders the new card as a sibling of the existing water-log smartsheet card. Permission gate uses can_manage_settings + platform_admin (not can_manage_water_log) — feature is a brand-level integration setting, not water-log-specific. Out of scope / deferred: - /api/time-tracking/smartsheet-sync cron route. The trigger is optional: realtime mode syncs inline; 15-min/hourly modes need the cron. Add in a follow-up cycle when the cron infrastructure is revisited. - drag-and-drop column-mapping UX (current card uses plain dropdowns). - backfill flow (no equivalent of the water-log "backfill past N days" feature yet). - can_manage_time_tracking permission flag (uses can_manage_settings as the proxy; revisit once the admin_users schema gets a time-tracking-specific permission column).
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
"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<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,
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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]">
|
||||
Smartsheet (Time Tracking)
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
|
||||
Push every closed clock-out to a configured Smartsheet sheet.
|
||||
Same encryption + retry policy as the Water Log smartsheet
|
||||
integration.
|
||||
</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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
|
||||
API token
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={state.showTokenInput ? "text" : "password"}
|
||||
placeholder={
|
||||
state.config?.hasToken && !state.showTokenInput
|
||||
? state.config.maskedToken ?? "••••••••"
|
||||
: "paste new token here"
|
||||
}
|
||||
value={state.tokenInput}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#e6dfd1] bg-white px-2 py-1 text-xs font-semibold text-[rgba(60,60,67,0.65)] hover:bg-[#fdfaf2]"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_SHOW_TOKEN",
|
||||
value: !state.showTokenInput,
|
||||
})
|
||||
}
|
||||
>
|
||||
{state.showTokenInput ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
{state.config?.hasToken && !state.tokenInput && (
|
||||
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
|
||||
A token is saved. Leave blank to keep it.
|
||||
</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;
|
||||
Reference in New Issue
Block a user