/** * Email campaign table (BUILD-2285 / BUILD-2283): one column factory used by both the * Email Marketing **Overview** tab ("Recent campaigns") and the **Campaigns** tab (full * list, with toolbar) so the two stay column-consistent — GHL-style: Campaign · Status · * Recipients · Delivered · Opened · Clicked · Bounced · Schedule, plus an optional * row-actions ("…") column when action handlers are supplied. * * Rates are numeric percentages; pass `formatRate` to change the default one-decimal * "42.1%" rendering. */ import { type ReactElement, useMemo, useState } from "react"; import { MoreHorizontal, Plus, Search, Send } from "lucide-react"; import type { ColumnDef, PaginationState } from "@tanstack/react-table"; import { Button } from "./button"; import { DataTable } from "./data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "./empty"; import { InfoTooltip } from "./info-tooltip"; import { Input } from "./input"; import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; import { EmailStatusBadge, type EmailStatus } from "./email-marketing-primitives"; /** One campaign row — shared by the Overview recent list and the Campaigns tab. */ export interface EmailCampaignRow { id: string; name: string; status: EmailStatus; templateName?: string; /** Staff who created it — its audience is whatever contacts that person can see. */ createdByName?: string; scheduleSummary?: string; /** Undefined while the count is still being fetched — the cell shows a pending marker. */ recipientCount?: number; /** Engagement + delivery rates as percentages (e.g. 42.1) — present once sending started. */ openRate?: number; clickRate?: number; /** SES-tracked delivery / bounce rates (once sending has started). */ deliveredRate?: number; bounceRate?: number; /** Why the campaign errored — shown beside the badge; only set for `status: "error"`. */ errorReason?: string; /** Recurring campaigns only — surfaced under Schedule. */ isRecurring?: boolean; lastSentLabel?: string; nextSendLabel?: string; } export type CampaignRateFormatter = (value: number) => string; const defaultFormatRate: CampaignRateFormatter = (value) => `${value.toFixed(1)}%`; export interface CampaignColumnHandlers { /** Row title click → open the campaign (status-aware in the parent). */ onOpenCampaign?: (id: string) => void; /** Row "…" actions — supplying any of these adds the actions column. */ onEditCampaign?: (id: string) => void; /** Pause a running campaign / resume a paused one (label flips by status). */ onTogglePause?: (id: string) => void; onDeleteCampaign?: (id: string) => void; /** Renders every rate column; defaults to one-decimal percent. */ formatRate?: CampaignRateFormatter; } function CampaignActionsCell({ row, onEditCampaign, onTogglePause, onDeleteCampaign, }: { row: EmailCampaignRow } & CampaignColumnHandlers): ReactElement { // An errored campaign's setup is broken beyond editing — deleting it is the only move left. const isInert = row.status === "error"; return ( } /> {onEditCampaign && !isInert ? { onEditCampaign(row.id); }}> Edit : null} {/* Pause/Resume only applies to campaigns that are still in flight — a scheduled send, a recurring series, or an already-paused one. An immediate one-off send can't be paused. */} {onTogglePause && !isInert && (row.status === "scheduled" || row.status === "paused" || (row.status === "sending" && row.isRecurring)) ? { onTogglePause(row.id); }}> {row.status === "paused" ? "Resume" : "Pause"} : null} {onDeleteCampaign ? <> { onDeleteCampaign(row.id); }} variant="destructive" > Delete > : null} ); } /** * Build the campaign table columns. Always returns the base GHL columns; if any * row-action handler is passed, appends a trailing "…" actions column. */ export function buildCampaignColumns( handlers: CampaignColumnHandlers = {}, ): ColumnDef[] { const { onOpenCampaign, onEditCampaign, onTogglePause, onDeleteCampaign, formatRate = defaultFormatRate, } = handlers; const rate = (value: number | undefined): string => value === undefined ? "—" : formatRate(value); const columns: ColumnDef[] = [ { accessorKey: "name", header: () => ( Campaign ), cell: ({ row }) => ( onOpenCampaign?.(row.original.id)} type="button" > {row.original.name} {row.original.templateName || row.original.createdByName ? ( {[row.original.templateName, row.original.createdByName] .filter(Boolean) .join(" · ")} ) : null} ), }, { accessorKey: "status", header: "Status", cell: ({ row }) => ( {row.original.status === "error" && row.original.errorReason ? ( // An errored campaign cannot be opened, so the reason has to be reachable here. ) : null} ), }, { accessorKey: "recipientCount", header: () => ( Recipients ), cell: ({ row }) => { const count = row.original.recipientCount; return ( {count === undefined ? "…" : count.toLocaleString()} ); }, }, { id: "deliveredRate", header: "Delivered", cell: ({ row }) => ( {rate(row.original.deliveredRate)} ), }, { id: "openRate", header: "Opened", cell: ({ row }) => ( {rate(row.original.openRate)} ), }, { id: "clickRate", header: "Clicked", cell: ({ row }) => ( {rate(row.original.clickRate)} ), }, { id: "bounceRate", header: "Bounced", cell: ({ row }) => { const v = row.original.bounceRate; return ( {rate(v)} ); }, }, { accessorKey: "scheduleSummary", header: "Schedule", cell: ({ row }) => { const { scheduleSummary, isRecurring, lastSentLabel, nextSendLabel } = row.original; return ( {scheduleSummary ?? "—"} {isRecurring && (lastSentLabel || nextSendLabel) ? Last sent {lastSentLabel ?? "—"} · Next {nextSendLabel ?? "—"} : null} ); }, }, ]; const hasActions = onEditCampaign || onTogglePause || onDeleteCampaign; if (hasActions) { columns.push({ id: "actions", header: "", cell: ({ row }) => ( ), }); } return columns; } /** Statuses a campaign can filter by (drip-only `active`/`ended` excluded). */ export type CampaignStatusFilter = Extract< EmailStatus, "scheduled" | "sending" | "sent" | "paused" | "error" >; const STATUS_FILTERS: (CampaignStatusFilter | "all")[] = [ "all", "scheduled", "sending", "sent", "paused", "error", ]; /** * Server-driven campaign table. Supply it and the component renders exactly the rows it is * given, reporting status/search/page intent through callbacks; omit it and the table filters * and pages the rows it holds, which is only correct when those rows are every campaign. */ export interface EmailCampaignTableQuery { status: CampaignStatusFilter | "all"; onStatusChange: (status: CampaignStatusFilter | "all") => void; search: string; onSearchChange: (search: string) => void; pagination: PaginationState; onPaginationChange: (pagination: PaginationState) => void; /** Campaigns matching the active filters, across every page. */ rowCount: number; /** Per-status totals for the whole set; omit to count the loaded rows. */ statusCounts?: Partial>; isLoading?: boolean; } export interface EmailCampaignTableProps extends CampaignColumnHandlers { campaigns: EmailCampaignRow[]; onNewCampaign?: () => void; className?: string; /** Omit for the client-side table; supply for server-driven filtering/search/paging. */ query?: EmailCampaignTableQuery; } export function EmailCampaignTable({ campaigns, onNewCampaign, className, query: serverQuery, ...handlers }: EmailCampaignTableProps): ReactElement { const [localQuery, setLocalQuery] = useState(""); const [localStatus, setLocalStatus] = useState( "all", ); // Server-driven mode owns search and status so the request and the controls cannot disagree. const query = serverQuery?.search ?? localQuery; const setQuery = serverQuery?.onSearchChange ?? setLocalQuery; const status = serverQuery?.status ?? localStatus; const setStatus = serverQuery?.onStatusChange ?? setLocalStatus; const counts = useMemo(() => { if (serverQuery?.statusCounts) return serverQuery.statusCounts; const c: Partial> = {}; for (const campaign of campaigns) { c[campaign.status] = (c[campaign.status] ?? 0) + 1; } return c; }, [campaigns, serverQuery?.statusCounts]); // Summed rather than `rowCount`: that counts the rows under every active filter, the status // chip included, so the All chip would shrink to whichever status was selected. const allCount = useMemo( () => serverQuery?.statusCounts ? Object.values(serverQuery.statusCounts).reduce( (sum, n) => sum + (n ?? 0), 0, ) : (serverQuery?.rowCount ?? campaigns.length), [campaigns.length, serverQuery?.rowCount, serverQuery?.statusCounts], ); // Rows arrive pre-filtered when the server owns the query; filtering again would drop rows // the backend deliberately included. const filtered = useMemo(() => { if (serverQuery) return campaigns; const q = query.trim().toLowerCase(); return campaigns.filter((c) => { const matchesStatus = status === "all" || c.status === status; const matchesQuery = !q || c.name.toLowerCase().includes(q) || (c.templateName?.toLowerCase().includes(q) ?? false); return matchesStatus && matchesQuery; }); }, [campaigns, query, status, serverQuery]); const columns = useMemo(() => buildCampaignColumns(handlers), [handlers]); return ( { setQuery(e.target.value); }} placeholder="Search campaigns…" value={query} /> { const next = Array.isArray(v) ? v[0] : v; if (next) setStatus(next as CampaignStatusFilter | "all"); }} type="single" value={[status]} variant="outline" > {STATUS_FILTERS.map((s) => ( {s === "all" ? "All" : s} {s === "all" ? allCount : (counts[s] ?? 0)} ))} {onNewCampaign ? New Campaign : null} {campaigns.length === 0 ? ( No campaigns yet Send your first templated campaign to a group of clients. ) : ( )} ); } export default EmailCampaignTable;
{row.original.name}
{[row.original.templateName, row.original.createdByName] .filter(Boolean) .join(" · ")}
{scheduleSummary ?? "—"}
Last sent {lastSentLabel ?? "—"} · Next {nextSendLabel ?? "—"}