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>
);
}
|