From 203a3e047189b5ef4cb79e97b510abb03e685f5f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:50:14 +0200 Subject: [PATCH] feat: add contacts sheet sync UI --- .../components/app/contacts/ContactsTable.tsx | 23 + .../app/contacts/SheetSyncWizard.tsx | 726 ++++++++++++++++++ .../app/contacts/SyncSourceEditDrawer.tsx | 260 +++++++ .../app/contacts/SyncSourcesPanel.tsx | 358 +++++++++ 4 files changed, 1367 insertions(+) create mode 100644 web/src/components/app/contacts/SheetSyncWizard.tsx create mode 100644 web/src/components/app/contacts/SyncSourceEditDrawer.tsx create mode 100644 web/src/components/app/contacts/SyncSourcesPanel.tsx diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 14be1602..0cc7a3b1 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -23,6 +23,7 @@ import { PlusIcon, RefreshCcwIcon, Settings2Icon, + SheetIcon, TrashIcon, UploadIcon, UserPlusIcon, @@ -43,6 +44,7 @@ import ContactsEditBulk from "./ContactsEditBulk"; import { NewContactDialog } from "./NewContactDialog"; import ExportDialog from "./ExportDialog"; import ImportWizard from "./ImportWizard"; +import SyncSourcesPanel from "./SyncSourcesPanel"; import { CategoryChip } from "./CategoryPicker"; import { @@ -90,6 +92,7 @@ export default function ContactsTable({ const [newOpen, setNewOpen] = React.useState(false); const [exportOpen, setExportOpen] = React.useState(false); const [importOpen, setImportOpen] = React.useState(false); + const [syncOpen, setSyncOpen] = React.useState(false); const [searchProps, setSearchProps] = React.useState({ query: "", @@ -227,6 +230,13 @@ export default function ContactsTable({ > Filters + } + onClick={() => setSyncOpen(true)} + > + Sheet sync + } onClick={() => setNewOpen(true)} @@ -264,6 +274,11 @@ export default function ContactsTable({ /> setNewOpen(false)} /> + setSyncOpen(false)} + campaign={current_campaign} + /> ); } @@ -287,6 +302,13 @@ export default function ContactsTable({ > Import + } + onClick={() => setSyncOpen(true)} + > + Sheet sync + } @@ -435,6 +457,7 @@ export default function ContactsTable({ open={importOpen} onClose={() => setImportOpen(false)} /> + setSyncOpen(false)} /> ); } diff --git a/web/src/components/app/contacts/SheetSyncWizard.tsx b/web/src/components/app/contacts/SheetSyncWizard.tsx new file mode 100644 index 00000000..54ad56c8 --- /dev/null +++ b/web/src/components/app/contacts/SheetSyncWizard.tsx @@ -0,0 +1,726 @@ +// SheetSyncWizard — multi-step modal for creating (or editing) an on-demand +// Google-Sheet → contacts "sync source", and optionally running the first +// "Sync now". Mirrors ImportWizard's dialog shell + house theme, and REUSES +// its column-mapper verbatim (TargetPicker + MapStep + DEDUP_OPTIONS) so the +// /lead-sync/google/preview ImportPreview is mapped with the exact same UI as +// a CSV import. +// +// Steps: +// 1. connect — if no hidden google_sheets OAuth connection exists, run the +// EXISTING integration OAuth popup (provider "google_sheets"). +// 2. sheet — paste a Sheet ID, fetch its tabs, pick a tab. +// 3. map — preview first rows + map columns (reused MapStep). +// 4. options — dedup strategy, optional target campaign, optional categories. +// 5. save — POST /lead-sync/sources, then optionally Sync now → result. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertTriangleIcon, + ArrowLeftIcon, + ArrowRightIcon, + CheckIcon, + Loader2Icon, + PlugZapIcon, + RefreshCwIcon, + SaveIcon, + SheetIcon, + XIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { MapStep, ResultStep } from "./ImportWizard"; +import { DEDUP_OPTIONS, announceResult, describeError } from "./importShared"; +import CategoryPicker from "./CategoryPicker"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import { + useFinishIntegrationOAuth, + useStartIntegrationOAuth, +} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth"; +import { openOAuthPopup } from "@/lib/integrations/oauthPopup"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useGoogleConnection from "@/lib/api/hooks/app/leadsync/useGoogleConnection"; +import { + useGetSpreadsheet, + usePreviewSheet, +} from "@/lib/api/hooks/app/leadsync/useSheetMeta"; +import useCreateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useCreateLeadSyncSource"; +import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, + LeadSyncSource, + SheetMeta, +} from "@/lib/api/models/app/leadsync/LeadSync"; + +type Step = "connect" | "sheet" | "map" | "options" | "result"; + +interface Props { + open: boolean; + onClose: () => void; + // When set, the source is pre-targeted to this campaign and the campaign + // picker is hidden — used by the per-campaign "Connect a Google Sheet". + lockedCampaign?: { id: string; name: string }; + // Notified after a source is saved so callers can refresh their list. + onSaved?: (source: LeadSyncSource) => void; +} + +const STEP_ORDER: Step[] = ["connect", "sheet", "map", "options", "result"]; + +export default function SheetSyncWizard({ open, onClose, lockedCampaign, onSaved }: Props) { + const connection = useGoogleConnection(); + const connectionId = connection.data?.connection?.id ?? null; + const connected = !!connection.data?.connected && !!connectionId; + + const [step, setStep] = React.useState("connect"); + const [sheetId, setSheetId] = React.useState(""); + const [meta, setMeta] = React.useState(null); + const [tabTitle, setTabTitle] = React.useState(""); + const [preview, setPreview] = React.useState(null); + const [mapping, setMapping] = React.useState([]); + const [hasHeader, setHasHeader] = React.useState(true); + const [dedup, setDedup] = React.useState("update"); + const [campaignId, setCampaignId] = React.useState(lockedCampaign?.id ?? null); + const [campaignName, setCampaignName] = React.useState(lockedCampaign?.name ?? ""); + const [categoryIds, setCategoryIds] = React.useState([]); + const [label, setLabel] = React.useState(""); + const [result, setResult] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const startOAuth = useStartIntegrationOAuth(); + const finishOAuth = useFinishIntegrationOAuth(); + const getSpreadsheet = useGetSpreadsheet(); + const previewSheet = usePreviewSheet(); + const createSource = useCreateLeadSyncSource(); + const syncSource = useSyncLeadSyncSource(); + const queryClient = useQueryClient(); + + const reset = React.useCallback(() => { + setStep("connect"); + setSheetId(""); + setMeta(null); + setTabTitle(""); + setPreview(null); + setMapping([]); + setHasHeader(true); + setDedup("update"); + setCampaignId(lockedCampaign?.id ?? null); + setCampaignName(lockedCampaign?.name ?? ""); + setCategoryIds([]); + setLabel(""); + setResult(null); + setBusy(false); + }, [lockedCampaign]); + + React.useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + // Skip straight to the sheet step once we know a connection exists. + React.useEffect(() => { + if (open && step === "connect" && connected) setStep("sheet"); + }, [open, step, connected]); + + async function runConnect() { + setBusy(true); + try { + const { url } = await startOAuth.mutateAsync({ + provider: "google_sheets", + label: "Google Sheets", + }); + const { code, state } = await openOAuthPopup(url); + await finishOAuth.mutateAsync({ code, state }); + await connection.refetch(); + await queryClient.invalidateQueries({ queryKey: ["lead-sync", "google", "connection"] }); + toast.success("Connected to Google Sheets"); + setStep("sheet"); + } catch (err) { + toast.error(describeError(err, "Connection failed.")); + } finally { + setBusy(false); + } + } + + async function loadTabs() { + if (!connectionId) return; + const id = sheetId.trim(); + if (!id) { + toast.error("Paste a Sheet ID first."); + return; + } + setBusy(true); + try { + const m = await getSpreadsheet.mutateAsync({ connection_id: connectionId, sheet_id: id }); + setMeta(m); + // Auto-select the first tab so the picker is never empty. + const first = m.tabs[0]?.title ?? ""; + setTabTitle(first); + if (!label.trim()) setLabel(m.title); + } catch (err) { + toast.error(describeError(err, "Couldn't read that spreadsheet.")); + setMeta(null); + } finally { + setBusy(false); + } + } + + async function loadPreview() { + if (!connectionId || !meta) return; + if (!tabTitle) { + toast.error("Pick a tab first."); + return; + } + setBusy(true); + try { + const p = await previewSheet.mutateAsync({ + connection_id: connectionId, + sheet_id: meta.sheet_id, + tab_title: tabTitle, + }); + setPreview(p); + setMapping(p.suggested_mapping); + setHasHeader(p.has_header); + setStep("map"); + } catch (err) { + toast.error(describeError(err, "Couldn't read that tab.")); + } finally { + setBusy(false); + } + } + + const emailMapped = mapping.some((m) => m.target === "email"); + + async function save(runSync: boolean) { + if (!connectionId || !meta) return; + setBusy(true); + try { + const source = await createSource.mutateAsync({ + connection_id: connectionId, + sheet_id: meta.sheet_id, + sheet_title: meta.title, + tab_title: tabTitle, + has_header: hasHeader, + column_mapping: mapping, + dedup, + target_campaign_id: campaignId ?? undefined, + category_ids: categoryIds, + subscribed_default: true, + label: label.trim() || meta.title, + }); + onSaved?.(source); + if (runSync) { + const res = await syncSource.mutateAsync(source.id); + setResult(res.result); + setStep("result"); + announceResult(res.result); + } else { + toast.success("Sync source saved"); + onClose(); + } + } catch (err) { + toast.error(describeError(err, "Couldn't save the sync source.")); + } finally { + setBusy(false); + } + } + + function stepIndex(): number { + // Hide the connect dot once connected — the visible flow is 4 steps. + const visible = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER; + return Math.max(0, visible.indexOf(step)); + } + const visibleSteps = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER; + + return ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[760px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[90vh]" + > +
+
+ +
+ + Sync source + +
+ Google Sheet +
+ {visibleSteps.map((_, idx) => ( + + ))} +
+ +
+ +
+ {step === "connect" && ( + + )} + {step === "sheet" && ( + + )} + {step === "map" && preview && ( + + )} + {step === "options" && ( + { + setCampaignId(id); + setCampaignName(name); + }} + lockedCampaign={lockedCampaign} + categoryIds={categoryIds} + setCategoryIds={setCategoryIds} + /> + )} + {step === "result" && result && ( + + )} +
+ +
+ {step === "connect" && ( + + We use your existing Google Sheets authorization. + + )} + {step === "sheet" && ( + <> + {connected && !lockedCampaign && ( + + Connected to Google. + + )} + + + )} + {step === "map" && ( + <> + + {!emailMapped && ( + + + Map a column to Email + + )} + + + )} + {step === "options" && ( + <> + + + + + )} + {step === "result" && ( + + )} +
+
+
+ )} +
+ ); +} + +// ----- Connect step ---------------------------------------------- + +function ConnectStep({ busy, onConnect }: { busy: boolean; onConnect: () => void }) { + return ( +
+
+
+ +
+

Connect Google Sheets

+

+ Authorize Warmbly to read your spreadsheets. We only read the rows of the tab + you choose — nothing is written back. +

+ +
+
+
    +
  • One row per contact — first row should be the column headers.
  • +
  • At minimum a column with email addresses.
  • +
  • This is on-demand: nothing syncs until you press Sync now.
  • +
+
+
+ ); +} + +// ----- Sheet + tab step ------------------------------------------ + +function SheetStep({ + sheetId, + setSheetId, + meta, + tabTitle, + setTabTitle, + onLoadTabs, + busy, +}: { + sheetId: string; + setSheetId: (v: string) => void; + meta: SheetMeta | null; + tabTitle: string; + setTabTitle: (v: string) => void; + onLoadTabs: () => void; + busy: boolean; +}) { + return ( +
+
+ +
+ + +
+

+ The long ID in the sheet URL between /d/ and{" "} + /edit. +

+
+ + {meta && ( +
+
+ + {meta.title} + {meta.tabs.length} tabs +
+ + + + + + + Tabs + {meta.tabs.map((t) => ( + setTabTitle(t.title)} + > + {t.title} + + ))} + + +
+ )} +
+ ); +} + +// ----- Options step ---------------------------------------------- + +function OptionsStep({ + dedup, + setDedup, + label, + setLabel, + campaignId, + campaignName, + onCampaign, + lockedCampaign, + categoryIds, + setCategoryIds, +}: { + dedup: ImportDedupStrategy; + setDedup: (v: ImportDedupStrategy) => void; + label: string; + setLabel: (v: string) => void; + campaignId: string | null; + campaignName: string; + onCampaign: (id: string | null, name: string) => void; + lockedCampaign?: { id: string; name: string }; + categoryIds: string[]; + setCategoryIds: (v: string[]) => void; +}) { + return ( +
+
+ + +

+ Shown in your Sync sources list. Defaults to the spreadsheet title. +

+
+ +
+

+ Duplicate handling +

+

+ We dedupe on lowercased email each time you sync. Decide what happens when a row + matches a contact you already have. +

+
+ {DEDUP_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

+ Enroll in campaign +

+ {lockedCampaign ? ( +
+ + + New & updated leads join{" "} + {lockedCampaign.name}. + +
+ ) : ( + <> +

+ Optionally enroll every synced contact into a campaign. +

+ + + )} +
+ +
+

+ Apply categories +

+

+ Every synced contact gets these categories. Skip to leave them untagged. +

+ +
+
+ ); +} + +// CampaignPicker — house-theme PopoverMenu campaign selector backed by the +// existing campaigns list query. Single-select with an explicit "None". +function CampaignPicker({ + campaignId, + campaignName, + onChange, +}: { + campaignId: string | null; + campaignName: string; + onChange: (id: string | null, name: string) => void; +}) { + const [query, setQuery] = React.useState(""); + const campaigns = useCampaigns({ query, folder: "" }); + const label = campaignId ? campaignName || "Selected campaign" : "No campaign"; + + return ( + + + + + +
+ setQuery(e.target.value)} + placeholder="Search campaigns…" + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ onChange(null, "")}> + No campaign + + {campaigns.campaigns.map((c) => ( + onChange(c.id, c.name)} + > + {c.name} + + ))} + {campaigns.campaigns.length === 0 && ( +
+ {campaigns.isPending ? "Loading…" : "No campaigns found."} +
+ )} +
+
+ ); +} diff --git a/web/src/components/app/contacts/SyncSourceEditDrawer.tsx b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx new file mode 100644 index 00000000..63393788 --- /dev/null +++ b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx @@ -0,0 +1,260 @@ +// SyncSourceEditDrawer — edit a saved sync source's options without re-running +// the column mapper. Editing the sheet/tab/mapping is a "make a new source" +// operation conceptually, so here we only expose the safe, common edits: +// label, dedup, target campaign (with detach), categories, and the header flag. +// Sheet/tab/mapping are shown read-only for context. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { DEDUP_OPTIONS, describeError } from "./importShared"; +import CategoryPicker from "./CategoryPicker"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useUpdateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource"; +import type { + ImportDedupStrategy, + LeadSyncSource, + UpdateLeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; + +export default function SyncSourceEditDrawer({ + source, + onClose, +}: { + source: LeadSyncSource; + onClose: () => void; +}) { + const update = useUpdateLeadSyncSource(); + const [query, setQuery] = React.useState(""); + const campaigns = useCampaigns({ query, folder: "" }); + + const [label, setLabel] = React.useState(source.label ?? ""); + const [dedup, setDedup] = React.useState(source.dedup); + const [hasHeader, setHasHeader] = React.useState(source.has_header); + const [campaignId, setCampaignId] = React.useState( + source.target_campaign_id ?? null, + ); + const [categoryIds, setCategoryIds] = React.useState(source.category_ids ?? []); + const [busy, setBusy] = React.useState(false); + + const campaignName = + campaigns.campaigns.find((c) => c.id === campaignId)?.name ?? + (campaignId ? "Selected campaign" : "No campaign"); + + async function save() { + setBusy(true); + try { + const body: UpdateLeadSyncSource = { + label: label.trim() || source.sheet_title || "Sync source", + dedup, + has_header: hasHeader, + category_ids: categoryIds, + }; + // A nil pointer can't express "clear", so detach explicitly. + if (campaignId) { + body.target_campaign_id = campaignId; + } else if (source.target_campaign_id) { + body.clear_campaign = true; + } + await update.mutateAsync({ id: source.id, body }); + toast.success("Sync source updated"); + onClose(); + } catch (err) { + toast.error(describeError(err, "Couldn't update the sync source.")); + } finally { + setBusy(false); + } + } + + return ( + + + + + +
+
+ + {source.tab_title && } + m.target !== "ignore").length} fields`} + /> +
+ +
+ + +
+ +
+ +
+ +
+

+ Duplicate handling +

+
+ {DEDUP_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

+ Enroll in campaign +

+ + + + + +
+ setQuery(e.target.value)} + placeholder="Search campaigns…" + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ setCampaignId(null)} + > + No campaign + + {campaigns.campaigns.map((c) => ( + setCampaignId(c.id)} + > + {c.name} + + ))} +
+
+
+ +
+

+ Apply categories +

+ +
+
+ +
+ + +
+
+ +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/components/app/contacts/SyncSourcesPanel.tsx b/web/src/components/app/contacts/SyncSourcesPanel.tsx new file mode 100644 index 00000000..346cf1dd --- /dev/null +++ b/web/src/components/app/contacts/SyncSourcesPanel.tsx @@ -0,0 +1,358 @@ +// SyncSourcesPanel — the management surface for on-demand Google-Sheet → leads +// "sync sources". Lists saved sources with their last-sync result + status, and +// offers per-row Sync now / Edit / Delete plus a "New sync" entry that opens the +// SheetSyncWizard. Works in two placements: +// - global Contacts page (no campaignId): lists every source. +// - per-campaign leads view (campaignId set): lists that campaign's sources +// and pre-targets the wizard to it. +// +// Rendered as a centered modal mirroring ImportWizard's dialog shell + theme. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + Loader2Icon, + PencilIcon, + PlusIcon, + RefreshCwIcon, + SheetIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { announceResult, describeError } from "./importShared"; +import SheetSyncWizard from "./SheetSyncWizard"; +import SyncSourceEditDrawer from "./SyncSourceEditDrawer"; +import { useConfirm } from "@/hooks/context/confirm"; +import useLeadSyncSources from "@/lib/api/hooks/app/leadsync/useLeadSyncSources"; +import useDeleteLeadSyncSource from "@/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource"; +import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource"; +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; + +export default function SyncSourcesPanel({ + open, + onClose, + campaign, +}: { + open: boolean; + onClose: () => void; + // When set, scopes the list + pre-targets new sources to this campaign. + campaign?: { id: string; name: string }; +}) { + const sources = useLeadSyncSources(campaign?.id); + const deleteSource = useDeleteLeadSyncSource(); + const syncSource = useSyncLeadSyncSource(); + const confirm = useConfirm(); + + const [wizardOpen, setWizardOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const [syncingId, setSyncingId] = React.useState(null); + + const list = sources.data?.data ?? []; + + async function runSync(src: LeadSyncSource) { + setSyncingId(src.id); + try { + const res = await syncSource.mutateAsync(src.id); + announceResult(res.result); + } catch (err) { + toast.error(describeError(err, "Sync failed.")); + } finally { + setSyncingId(null); + } + } + + function confirmDelete(src: LeadSyncSource) { + confirm.show( + `Delete sync source "${src.label || src.sheet_title || "this sheet"}"? Contacts already imported stay.`, + async () => { + try { + await deleteSource.mutateAsync(src.id); + toast.success("Sync source deleted"); + } catch (err) { + toast.error(describeError(err, "Delete failed.")); + } + }, + ); + } + + return ( + <> + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[720px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[85vh]" + > +
+
+ +
+ + Sync sources + + {campaign && ( + <> +
+ + {campaign.name} + + + )} + + +
+ +
+ {sources.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : sources.isError ? ( +
+
+ +
+

+ Couldn't load sync sources +

+ +
+ ) : list.length === 0 ? ( +
+
+ +
+

+ No sync sources yet +

+

+ Connect a Google Sheet and re-run it on demand to pull new and + updated leads into Warmbly{campaign ? ` and into ${campaign.name}` : ""}. +

+ +
+ ) : ( +
+ {list.map((src) => ( + runSync(src)} + onEdit={() => setEditing(src)} + onDelete={() => confirmDelete(src)} + /> + ))} +
+ )} +
+ + + )} + + + setWizardOpen(false)} + lockedCampaign={campaign} + onSaved={() => sources.refetch()} + /> + {editing && ( + setEditing(null)} /> + )} + + ); +} + +function SourceRow({ + source, + syncing, + onSync, + onEdit, + onDelete, +}: { + source: LeadSyncSource; + syncing: boolean; + onSync: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const r = source.last_result; + return ( +
+
+ +
+
+
+ + {source.label || source.sheet_title || "Untitled sheet"} + + +
+
+ {source.sheet_title && ( + {source.sheet_title} + )} + {source.tab_title && ( + <> + · + {source.tab_title} + + )} + · + + {source.last_synced_at + ? `synced ${new Date(source.last_synced_at).toLocaleString()}` + : "never synced"} + +
+ {r && ( +
+ + + + {r.failed > 0 && } +
+ )} + {source.last_error && ( +

{source.last_error}

+ )} +
+ +
+ + + +
+
+ ); +} + +function StatusBadge({ status, hasError }: { status: string; hasError: boolean }) { + if (status === "syncing") { + return ( + + + syncing + + ); + } + if (status === "error" || hasError) { + return ( + + + error + + ); + } + return ( + + + idle + + ); +} + +function Count({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "emerald" | "sky" | "slate" | "red"; +}) { + const cls = { + emerald: "bg-emerald-50 text-emerald-700", + sky: "bg-sky-50 text-sky-700", + slate: "bg-slate-100 text-slate-600", + red: "bg-red-50 text-red-700", + }[tone]; + return ( + + {value.toLocaleString()} + {label} + + ); +}