All files / src/components/crm LeadCard.tsx

100% Statements 11/11
100% Branches 35/35
100% Functions 5/5
100% Lines 11/11

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 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215                                                                                                                              434x 434x 434x 434x       434x       434x   434x     1x                                                                                                                                                                                                   1x                     4x                     4x                              
import { Link } from "@tanstack/react-router";
import { Bell, FileText, MessageCircle, StickyNote } from "lucide-react";
import type { Lead } from "../../lib/api/pro/crm";
import { abbreviatePaise } from "../../lib/currency-utils";
import { telUrl, whatsappUrl } from "../../lib/crm-constants";
import { cn } from "../../lib/utils";
import { StatusBadge } from "../ui/status-badge";
import { formatDueLabel, formatLeadAge, isLeadOverdue } from "./lead-overdue";
import { leadStatusCode, type StageLike } from "./lead-status";
import { scopeLabel } from "./lead-vocabulary";
 
/**
 * A lead, as the Job Book prints it on the pipeline board.
 *
 * `.context/mockups/leads-board.html` is the specification. Six printed
 * elements, in this order: the status code, the name, locality · scope, the
 * value with the age right-aligned against it, and one optional closing line
 * (the follow-up, the loss reason, or "not contacted yet").
 *
 * **THIS IS THE ONE PLACE IN THE APP THE −3px LIFT IS CORRECT.** The lift
 * means "pick-up-able" and these cards are dragged between stages, which is
 * exactly what that means. Rows tint; only draggables lift — so the lift is
 * bound to `canDrag`, and a card rendered without an `onDragStart` (the mobile
 * list, where there is no drag target) does not lift.
 *
 * WHAT THE RESKIN DELIBERATELY REMOVED
 * A four-colour "contact recency" dot (grey / `bg-success` / `bg-warning` /
 * `bg-error`) whose only label was a `title` attribute. It broke two rules at
 * once: it signalled state in colour alone, which no screen reader and no
 * colour-blind reader gets, and it put red on a card for a state — the Index
 * Red Rule reserves red for marking the current item, never for state. The
 * same information is now a printed line of text, which every reader gets.
 */
 
// ─── Props ───────────────────────────────────────────────────────────────────
 
type LeadCardProps = {
	lead: Lead;
	/** The stage this card sits in — the status code is derived from it. */
	stage?: StageLike;
	documentCount?: number;
	reminderCount?: number;
	nextReminderDueAt?: string | null;
	/** One reading of the clock for the whole board, passed down by the column. */
	now?: number;
	onDragStart?: (e: React.DragEvent, leadId: number) => void;
	onQuickNote?: (leadId: number) => void;
	onQuickReminder?: (leadId: number) => void;
};
 
// ─── Component ───────────────────────────────────────────────────────────────
 
export function LeadCard({
	lead,
	stage,
	documentCount = 0,
	reminderCount = 0,
	nextReminderDueAt,
	now = Date.now(),
	onDragStart,
	onQuickNote,
	onQuickReminder,
}: LeadCardProps) {
	const canDrag = !!onDragStart;
	const status = leadStatusCode({ nextReminderDueAt }, stage, now);
	const scope = scopeLabel(lead.projectType);
	const displayValue = lead.quoteValuePaise ?? lead.orderValuePaise ?? null;
 
	// The one closing line. A follow-up outranks a loss reason, which outranks
	// "nobody has called them yet" — the order a pro needs them in.
	const closingLine = nextReminderDueAt
		? `Follow-up ${formatDueLabel(nextReminderDueAt, now)}`
		: (lead.lossReason ?? (lead.lastContactedAt ? null : "Not contacted yet"));
 
	const meta = [lead.location, scope].filter(Boolean).join(" · ");
 
	return (
		<article
			draggable={canDrag}
			onDragStart={canDrag ? (e) => onDragStart(e, lead.id) : undefined}
			className={cn(
				"relative rounded-lg bg-background-elevated shadow-card",
				"transition-[box-shadow,transform] duration-200 ease-out",
				canDrag &&
					// The lift. `-translate-y-[3px]` is the design system's `.lift`
					// quantity; it is not on Tailwind's spacing scale (1 / 2 / 4px),
					// and the value belongs in @interioring/design-system next to
					// --shadow-card-hover once that package grows a motion section.
					"cursor-grab active:cursor-grabbing hover:-translate-y-[3px] hover:shadow-card-hover focus-within:-translate-y-[3px] focus-within:shadow-card-hover",
			)}
		>
			{/* The ring hole. One per object card, top-right, decorative. */}
			<span
				aria-hidden="true"
				className="absolute right-2.5 top-2.5 size-3 rounded-full border border-border-default bg-background-base shadow-sm"
			/>
 
			<div className="px-3 pb-3 pt-2.5">
				{/* The punch owns the top-right corner, so line one stops short. */}
				<div className="min-h-5 pr-5">
					{status && (
						<StatusBadge status={status.code} variant={status.tone} />
					)}
				</div>
 
				<Link
					to="/leads/$leadId"
					params={{ leadId: String(lead.id) }}
					className="mt-1.5 block truncate text-sm font-semibold text-foreground-default underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
				>
					{lead.customerName}
				</Link>
 
				{meta && (
					<p className="mt-0.5 truncate text-xs text-foreground-muted">
						{meta}
					</p>
				)}
 
				{/* Figures lead: the value left, the age right, both tabular. */}
				<p className="mt-1.5 flex items-baseline justify-between gap-2">
					{displayValue != null ? (
						<span className="text-sm font-semibold tabular-nums text-foreground-default">
							₹ {abbreviatePaise(displayValue)}
						</span>
					) : (
						<span className="text-xs text-foreground-subtle">No value yet</span>
					)}
					<span className="text-xs font-semibold uppercase tracking-wider tabular-nums text-foreground-subtle">
						{formatLeadAge(lead.dateCreated, now)}
					</span>
				</p>
 
				{closingLine && (
					<p
						className={cn(
							"mt-1 truncate text-xs tabular-nums",
							isLeadOverdue({ nextReminderDueAt }, now)
								? "font-semibold text-foreground-default"
								: "text-foreground-muted",
						)}
					>
						{closingLine}
					</p>
				)}
			</div>
 
			{/* The micro action strip: hairline, 24px tier, never a box. */}
			<div className="flex items-center gap-1 border-t border-border-default px-2 py-1">
				<a
					href={telUrl(lead.phone)}
					className="truncate text-xs tabular-nums text-foreground-muted underline-offset-2 hover:text-foreground-default hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
				>
					{lead.phone}
				</a>
				<span className="ml-auto flex items-center gap-0.5">
					{documentCount > 0 && (
						<span
							className="flex items-center gap-0.5 px-1 text-xs tabular-nums text-foreground-subtle"
							title={`${documentCount} documents`}
						>
							<FileText className="size-3" aria-hidden="true" />
							{documentCount}
						</span>
					)}
					{reminderCount > 0 && !nextReminderDueAt && (
						<span
							className="flex items-center gap-0.5 px-1 text-xs tabular-nums text-foreground-subtle"
							title={`${reminderCount} reminders`}
						>
							<Bell className="size-3" aria-hidden="true" />
							{reminderCount}
						</span>
					)}
					<button
						type="button"
						onClick={() =>
							window.open(whatsappUrl(lead.phone), "_blank", "noopener")
						}
						className="grid size-6 place-items-center rounded-sm text-foreground-subtle hover:bg-foreground-default/7 hover:text-foreground-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 pointer-coarse:size-11"
						title="Open WhatsApp"
					>
						<MessageCircle className="size-3.5" aria-hidden="true" />
						<span className="sr-only">Open WhatsApp for {lead.customerName}</span>
					</button>
					{onQuickNote && (
						<button
							type="button"
							onClick={() => onQuickNote(lead.id)}
							className="grid size-6 place-items-center rounded-sm text-foreground-subtle hover:bg-foreground-default/7 hover:text-foreground-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 pointer-coarse:size-11"
							title="Add note"
						>
							<StickyNote className="size-3.5" aria-hidden="true" />
							<span className="sr-only">Add a note on {lead.customerName}</span>
						</button>
					)}
					{onQuickReminder && (
						<button
							type="button"
							onClick={() => onQuickReminder(lead.id)}
							className="grid size-6 place-items-center rounded-sm text-foreground-subtle hover:bg-foreground-default/7 hover:text-foreground-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 pointer-coarse:size-11"
							title="Set reminder"
						>
							<Bell className="size-3.5" aria-hidden="true" />
							<span className="sr-only">
								Set a follow-up for {lead.customerName}
							</span>
						</button>
					)}
				</span>
			</div>
		</article>
	);
}