All files / src/components/crm LeadsTable.tsx

100% Statements 22/22
88.88% Branches 24/27
100% Functions 8/8
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195                                                                                                                                  62x 40x 17x   23x 2x                 90x 27x 90x     27x 62x   27x 40x       27x                                       62x 62x 62x 62x   62x                                                                                                                                                          
import { Link } from "@tanstack/react-router";
import type { KanbanData, KanbanLead } from "../../lib/api/pro/crm";
import { abbreviatePaise } from "../../lib/currency-utils";
import { cn } from "../../lib/utils";
import { StatusBadge } from "../ui/status-badge";
import {
	Table,
	TableBody,
	TableCaption,
	TableCell,
	TableFooter,
	TableHead,
	TableHeader,
	TableRow,
} from "../ui/table";
import { formatDueLabel, formatLeadAge } from "./lead-overdue";
import { isTerminalStage, leadStatusCode } from "./lead-status";
import { scopeLabel } from "./lead-vocabulary";
 
/**
 * The same leads as ruled rows.
 *
 * A solo studio reads rows faster than a board, so ☰ is not a grudging
 * secondary view — the mockup's own comment calls it "the one most pros will
 * leave on". Eight columns: lead, locality, scope, stage, status, value, age,
 * next step, with the open-pipeline total in the footer.
 *
 * Built on the `Table` primitive, which owns the hairlines, the row tint, the
 * tabular right-alignment and the index-red current-row bar. Nothing here
 * hand-rolls a `<table>`.
 *
 * ONE OVERRIDE THE PRIMITIVE NEEDS HERE, AND WHY
 * The lead cell is a `<th scope="row">` — a lead's name IS the row's header,
 * and a screen reader reading "₹ 18.4 L" wants "Priya Menon, value" not
 * "column 6". That costs two small overrides:
 *
 *  1. `TableHead` prints every `<th>` as a `.label` code, correct for a column
 *     head and wrong for a row head, so the lead cell resets to body type.
 *  2. `TableRow`'s `isCurrent` bar is selected as `[&>td:first-child]`, and
 *     this row's first cell is a `<th>`. The wider selector is added here —
 *     same token, same 3px, same rule. The mockup hits the identical problem
 *     and fixes it the identical way.
 */
 
type LeadsTableProps = {
	/** Already filtered and sorted by the page. */
	leads: readonly KanbanLead[];
	stages: KanbanData["stages"];
	/** One reading of the clock for the whole page. */
	now: number;
	/** The lead the page is currently about, if any. Gets the one 3px bar. */
	currentLeadId?: number | null;
};
 
/**
 * The "Next step" column: what this lead is waiting on, in a few words.
 *
 * Derived from the same `nextReminderDueAt` the status code and the overdue
 * band read — not a fourth opinion about follow-ups.
 */
function nextStep(
	lead: KanbanLead,
	stageType: string | undefined,
	now: number,
): string {
	if (stageType && isTerminalStage(stageType)) return "—";
	if (lead.nextReminderDueAt) {
		return `Call · ${formatDueLabel(lead.nextReminderDueAt, now)}`;
	}
	if (stageType === "system_entry") return "First call";
	return "No follow-up set";
}
 
export function LeadsTable({
	leads,
	stages,
	now,
	currentLeadId,
}: LeadsTableProps) {
	const stageById = new Map(stages.map((stage) => [stage.id, stage]));
	const terminalStageIds = new Set(
		stages.filter((s) => isTerminalStage(s.stageType)).map((s) => s.id),
	);
 
	const openLeads = leads.filter(
		(lead) => !terminalStageIds.has(lead.currentStageId),
	);
	const openPipelinePaise = openLeads.reduce(
		(sum, lead) => sum + (lead.quoteValuePaise ?? 0),
		0,
	);
 
	return (
		<div className="overflow-hidden rounded-lg bg-background-elevated shadow-card">
			<Table density="dense">
				<TableCaption>
					{`All ${leads.length} leads, with stage, status, value and next step`}
				</TableCaption>
				<TableHeader>
					<TableRow>
						<TableHead>Lead</TableHead>
						<TableHead>Locality</TableHead>
						<TableHead className="hidden sm:table-cell">Scope</TableHead>
						<TableHead>Stage</TableHead>
						<TableHead>Status</TableHead>
						<TableHead numeric>Value</TableHead>
						<TableHead numeric>Age</TableHead>
						<TableHead className="hidden sm:table-cell">Next step</TableHead>
					</TableRow>
				</TableHeader>
				<TableBody>
					{leads.map((lead) => {
						const stage = stageById.get(lead.currentStageId);
						const status = leadStatusCode(lead, stage, now);
						const value = lead.quoteValuePaise ?? lead.orderValuePaise ?? null;
						const isCurrent = currentLeadId === lead.id;
 
						return (
							<TableRow
								key={lead.id}
								isCurrent={isCurrent}
								className={cn(
									isCurrent &&
										"[&>th:first-child]:shadow-[inset_3px_0_0_var(--index)]",
								)}
							>
								<TableHead
									scope="row"
									/*
									 * `border-border-default` is part of the reset, not
									 * decoration: `TableHead` rules every `<th>` with the
									 * STRONG hairline (correct for a column head), which on
									 * a row head would print a heavy rule under the first
									 * cell of every row while the rest of the row carried
									 * the normal one. tailwind-merge collapses the pair.
									 */
									className="border-border-default text-sm font-semibold normal-case tracking-normal text-foreground-default"
								>
									<Link
										to="/leads/$leadId"
										params={{ leadId: String(lead.id) }}
										className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
									>
										{lead.customerName}
									</Link>
								</TableHead>
								<TableCell>{lead.location || "—"}</TableCell>
								<TableCell className="hidden sm:table-cell">
									{scopeLabel(lead.projectType) || "—"}
								</TableCell>
								<TableCell className="text-foreground-muted">
									{stage?.name ?? "—"}
								</TableCell>
								<TableCell>
									{status ? (
										<StatusBadge
											status={status.code}
											variant={status.tone}
										/>
									) : (
										<span className="text-foreground-subtle">—</span>
									)}
								</TableCell>
								<TableCell numeric>
									{value != null ? `₹ ${abbreviatePaise(value)}` : "—"}
								</TableCell>
								<TableCell numeric className="text-foreground-muted">
									{formatLeadAge(lead.dateCreated, now)}
								</TableCell>
								<TableCell className="hidden text-foreground-muted sm:table-cell">
									{nextStep(lead, stage?.stageType, now)}
								</TableCell>
							</TableRow>
						);
					})}
				</TableBody>
				<TableFooter>
					<TableRow>
						<TableCell
							colSpan={5}
							className="text-xs font-semibold uppercase tracking-wider text-foreground-muted"
						>
							{`Open pipeline — ${openLeads.length} ${
								openLeads.length === 1 ? "lead" : "leads"
							}`}
						</TableCell>
						<TableCell numeric>{`₹ ${abbreviatePaise(openPipelinePaise)}`}</TableCell>
						<TableCell colSpan={2} />
					</TableRow>
				</TableFooter>
			</Table>
		</div>
	);
}