All files / src/components/crm MobileLeadList.tsx

91.66% Statements 33/36
77.77% Branches 21/27
81.81% Functions 9/11
91.42% Lines 32/35

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                                                                                              124x     124x 75x 75x 294x 147x     75x     124x 1x 1x 1x     1x   1x       124x 92x 92x 362x   92x 216x 216x   92x     124x       124x 1x     123x                 490x             490x 490x 490x   490x                           1x                                                                               153x                                                                
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import type { KanbanData, KanbanLead } from "../../lib/api/pro/crm";
import { abbreviatePaise } from "../../lib/currency-utils";
import { Button } from "../ui/button";
import { Select } from "../ui/select";
import { LeadCard } from "./LeadCard";
import { isTerminalStage } from "./lead-status";
 
/**
 * The board, on a phone: the same stages as collapsible sections of cards.
 *
 * Cards here are NOT draggable — there is no drop target on a phone — so they
 * do not get the −3px lift either. The lift means "pick-up-able"; a card you
 * cannot pick up must not claim you can.
 *
 * The stage filter is the `Select` primitive, not a raw `<select>`: Phase 6
 * adds a grep test forbidding hand-rolled controls outside `components/ui/`,
 * and the primitive is where the control tiers, the 44px coarse-pointer floor
 * and the `aria-invalid` plumbing live.
 */
 
// ─── Types ───────────────────────────────────────────────────────────────────
 
type MobileLeadListProps = {
	stages: KanbanData["stages"];
	/** Already filtered and sorted by the page. */
	leads: readonly KanbanLead[];
	/** One reading of the clock for the whole page. */
	now: number;
	/** Absent when the member may not create a lead. */
	onAddLead?: () => void;
	onQuickNote?: (leadId: number) => void;
	onQuickReminder?: (leadId: number) => void;
};
 
// ─── Component ───────────────────────────────────────────────────────────────
 
export function MobileLeadList({
	stages,
	leads,
	now,
	onAddLead,
	onQuickNote,
	onQuickReminder,
}: MobileLeadListProps) {
	// Filter: null = show all, number = filter to one stage
	const [stageFilter, setStageFilter] = useState<number | null>(null);
 
	// Terminal stages start collapsed, non-terminal start expanded
	const [collapsed, setCollapsed] = useState<Set<number>>(() => {
		const ids = new Set<number>();
		for (const stage of stages) {
			if (isTerminalStage(stage.stageType)) {
				ids.add(stage.id);
			}
		}
		return ids;
	});
 
	const toggleSection = (stageId: number) => {
		setCollapsed((prev) => {
			const next = new Set(prev);
			Iif (next.has(stageId)) {
				next.delete(stageId);
			} else {
				next.add(stageId);
			}
			return next;
		});
	};
 
	const leadsByStage = useMemo(() => {
		const map = new Map<number, KanbanLead[]>();
		for (const stage of stages) {
			map.set(stage.id, []);
		}
		for (const lead of leads) {
			const arr = map.get(lead.currentStageId);
			Eif (arr) arr.push(lead);
		}
		return map;
	}, [stages, leads]);
 
	const visibleStages = stageFilter
		? stages.filter((s) => s.id === stageFilter)
		: stages;
 
	if (stages.length === 0) {
		return null; // KanbanBoard handles the empty-pipeline state
	}
 
	return (
		<div className="space-y-3">
			<Select
				aria-label="Filter by stage"
				value={stageFilter ?? ""}
				onChange={(e) =>
					setStageFilter(e.target.value ? Number(e.target.value) : null)
				}
				placeholder="All stages"
				options={stages.map((stage) => ({
					value: String(stage.id),
					label: `${stage.name} (${stage.leadCount})`,
				}))}
			/>
 
			{visibleStages.map((stage) => {
				const stageLeads = leadsByStage.get(stage.id) || [];
				const isOpen = !collapsed.has(stage.id);
				const isEntry = stage.stageType === "system_entry";
 
				return (
					/*
					 * `"<name> leads"`, not `"<name> stage"`: the desktop
					 * `KanbanColumn` for the same stage is also a landmark, and two
					 * regions with one name is a real ambiguity — CSS hides one of
					 * them, but the DOM (and a test, and anything walking the
					 * accessibility tree before layout) sees both. The names say
					 * what each one is: the column is a drop target for the stage,
					 * this is a disclosure group of its leads.
					 */
					<section key={stage.id} aria-label={`${stage.name} leads`}>
						{/* The quiet band, as a disclosure. */}
						<button
							type="button"
							onClick={() => toggleSection(stage.id)}
							aria-expanded={isOpen}
							className="flex w-full items-center gap-2 border-b border-border-strong pb-1.5 text-left transition-colors hover:bg-foreground-default/7 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
						>
							<span
								aria-hidden="true"
								className="h-[1.05em] w-0.75 shrink-0 rounded-[1px] bg-border-strong"
							/>
							{isOpen ? (
								<ChevronDown
									className="size-3.5 shrink-0 text-foreground-subtle"
									aria-hidden="true"
								/>
							) : (
								<ChevronRight
									className="size-3.5 shrink-0 text-foreground-subtle"
									aria-hidden="true"
								/>
							)}
							<span className="min-w-0 flex-1 truncate text-xs font-semibold uppercase tracking-wider text-foreground-muted">
								{stage.name}
							</span>
							{stage.totalValue > 0 && (
								<span className="text-xs tabular-nums text-foreground-subtle">
									₹ {abbreviatePaise(stage.totalValue)}
								</span>
							)}
							<span className="text-xs font-semibold tabular-nums text-foreground-muted">
								{stage.leadCount}
							</span>
						</button>
 
						{isOpen && (
							<div className="mt-2 space-y-2">
								{stageLeads.length === 0 ? (
									<p className="py-3 text-center text-xs text-foreground-subtle">
										No leads in this stage
									</p>
								) : (
									stageLeads.map((lead) => (
										<LeadCard
											key={lead.id}
											lead={lead}
											stage={stage}
											now={now}
											documentCount={lead.documentCount}
											reminderCount={lead.reminderCount}
											nextReminderDueAt={lead.nextReminderDueAt}
											onQuickNote={onQuickNote}
											onQuickReminder={onQuickReminder}
										/>
									))
								)}
								{isEntry && onAddLead && (
									<Button
										variant="outline"
										size="sm"
										className="w-full"
										onClick={onAddLead}
									>
										<Plus aria-hidden="true" />
										Add lead
									</Button>
								)}
							</div>
						)}
					</section>
				);
			})}
		</div>
	);
}