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 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | 475x 475x 475x 4x 3x 3x 3x 475x 1x 1x 475x 16x 15x 15x 15x 15x 14x 475x 475x 6x 469x 244x | import { ChevronLeft, Plus } from "lucide-react";
import { useCallback, useState } from "react";
import type { KanbanLead, PipelineStage } from "../../lib/api/pro/crm";
import { abbreviatePaise } from "../../lib/currency-utils";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { LeadCard } from "./LeadCard";
import { DENSE_BUTTON } from "./lead-controls";
import { isTerminalStage } from "./lead-status";
/**
* One stage of the pipeline board.
*
* `.context/mockups/leads-board.html` prints a column as a QUIET BAND over a
* stack of cards — `.band .band-quiet`: a 3px marker, the stage name in
* `.label` caps, and a right-aligned tabular count, ruled off with the strong
* hairline. No pill on the count, ever.
*
* WHY THE BAND IS QUIET AND NOT INDEX RED
* The Index Red Rule gives the page ONE index-red mark, for the current item.
* Five column heads carrying five red bars would spend that scarcity in a
* single row and leave red meaning nothing. The mockup's own comment says so;
* the marker here is `--border-strong`, and the one live index-red mark on the
* Leads page belongs to the overdue band.
*
* WHAT THE RESKIN REMOVED
* A 4px coloured top border per stage type (`border-t-primary-500` /
* `-success` / `-error`) plus a green trophy and a red cross icon. That is
* state in colour, on a surface whose state is already a printed code on every
* card; and the red one broke the Index Red Rule outright. Hairlines, not
* boxes; codes, not colour.
*/
// ─── Types ───────────────────────────────────────────────────────────────────
type StageWithCounts = PipelineStage & {
leadCount: number;
totalValue: number;
};
type KanbanColumnProps = {
stage: StageWithCounts;
leads: KanbanLead[];
/** One reading of the clock for the whole board. Defaults to this render's. */
now?: number;
/** False for staff: no drag target, no "move a lead here". Defaults true. */
canChangeStage?: boolean;
onDrop: (leadId: number, stageId: number) => void;
onAddLead?: () => void;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
onQuickNote?: (leadId: number) => void;
onQuickReminder?: (leadId: number) => void;
};
// ─── Component ───────────────────────────────────────────────────────────────
export function KanbanColumn({
stage,
leads,
now = Date.now(),
canChangeStage = true,
onDrop,
onAddLead,
isCollapsed,
onToggleCollapse,
onQuickNote,
onQuickReminder,
}: KanbanColumnProps) {
const [isDragOver, setIsDragOver] = useState(false);
const isTerminal = isTerminalStage(stage.stageType);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
if (!canChangeStage) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDragOver(true);
},
[canChangeStage],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
// Only count leaving the column itself, not child elements
Iif (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
if (!canChangeStage) return;
e.preventDefault();
setIsDragOver(false);
const leadId = Number(e.dataTransfer.getData("text/plain"));
if (leadId) {
onDrop(leadId, stage.id);
}
},
[canChangeStage, onDrop, stage.id],
);
const handleDragStart = useCallback((e: React.DragEvent, leadId: number) => {
e.dataTransfer.setData("text/plain", String(leadId));
e.dataTransfer.effectAllowed = "move";
}, []);
// Collapsed view for terminal stages — a spine, not a box.
if (isCollapsed) {
return (
<section
aria-label={`${stage.name} stage (collapsed)`}
className={cn(
"flex max-h-full w-14 min-w-14 flex-col transition-colors",
isDragOver && "bg-foreground-default/7",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<button
type="button"
aria-label={`Expand ${stage.name} column`}
className="flex w-full flex-1 cursor-pointer flex-col items-center gap-2 border-l border-border-strong pt-1.5 hover:bg-foreground-default/7 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
onClick={onToggleCollapse}
>
<span className="text-xs font-semibold tabular-nums text-foreground-muted">
{stage.leadCount}
</span>
<span className="flex flex-1 items-center justify-center">
<span className="text-xs font-semibold uppercase tracking-wider text-foreground-muted [text-orientation:mixed] [writing-mode:vertical-rl]">
{stage.name}
</span>
</span>
</button>
</section>
);
}
return (
<section
aria-label={`${stage.name} stage`}
className={cn(
"flex max-h-full min-w-0 flex-col gap-2 transition-colors",
isDragOver && "bg-foreground-default/7",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* The quiet band: 3px marker, .label name, tabular count, strong rule. */}
<div className="flex items-center gap-2 border-b border-border-strong pb-1.5">
<span
aria-hidden="true"
className="h-[1.05em] w-0.75 shrink-0 rounded-[1px] bg-border-strong"
/>
<h3 className="min-w-0 flex-1 truncate text-xs font-semibold uppercase tracking-wider text-foreground-muted">
{stage.name}
</h3>
{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>
{onAddLead && stage.stageType === "system_entry" && (
<button
type="button"
onClick={onAddLead}
className="grid size-6 shrink-0 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 lead"
>
<Plus className="size-4" aria-hidden="true" />
<span className="sr-only">Add a lead</span>
</button>
)}
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
className="grid size-6 shrink-0 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="Collapse column"
>
<ChevronLeft className="size-4" aria-hidden="true" />
<span className="sr-only">Collapse the {stage.name} column</span>
</button>
)}
</div>
{/* The cards. A terminal column reads back, not forward. */}
<div
className={cn(
"flex-1 space-y-2 overflow-y-auto pb-1",
isTerminal && "opacity-90",
)}
>
{leads.length === 0 ? (
<div className="py-6 text-center text-xs text-foreground-subtle">
{isDragOver ? (
"Drop here"
) : onAddLead ? (
<span className="flex flex-col items-center gap-2">
<span className="text-foreground-muted">
No leads in this stage
</span>
<Button
size="sm"
className={DENSE_BUTTON}
onClick={onAddLead}
>
<Plus aria-hidden="true" />
Add lead
</Button>
</span>
) : (
"No leads"
)}
</div>
) : (
leads.map((lead) => (
<LeadCard
key={lead.id}
lead={lead}
stage={stage}
now={now}
documentCount={lead.documentCount}
reminderCount={lead.reminderCount}
nextReminderDueAt={lead.nextReminderDueAt}
onDragStart={canChangeStage ? handleDragStart : undefined}
onQuickNote={onQuickNote}
onQuickReminder={onQuickReminder}
/>
))
)}
</div>
</section>
);
}
|