Files
clienthub/src/components/admin/leads/LeadsKanbanBoard.tsx
T
simone 9eb2d45c67 fix: sidebar width/centering + kanban card badge & table details
- Sidebar: expanded 256->200px, collapsed 80->64px; collapsed state now
  renders icon-only (label removed from flow) so icons are centered, not
  pushed left by an invisible text span
- Kanban cards: add StatusBadge + email line to match design reference
- Lead table: add "Mostrando N lead" footer and center/right-align
  Stato/Tag/Azioni headers per reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:28:43 +02:00

199 lines
6.6 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
DragEndEvent,
DragOverlay,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
useDroppable,
useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/pipeline/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
import { StatusBadge } from "@/components/ui/StatusBadge";
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-muted-foreground", dotClass: "bg-blue-400 dark:bg-blue-500" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700 dark:text-purple-400", dotClass: "bg-purple-400 dark:bg-purple-500" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700 dark:text-amber-400", dotClass: "bg-amber-400 dark:bg-amber-500" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700 dark:text-orange-400", dotClass: "bg-orange-400 dark:bg-orange-500" },
{ id: "won", label: "Vinto", headerClass: "text-emerald-700 dark:text-emerald-400", dotClass: "bg-emerald-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700 dark:text-red-400", dotClass: "bg-red-400 dark:bg-red-500" },
];
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
function DroppableColumn({
id, label, headerClass, dotClass, leads, activeId,
}: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
leads: LeadWithTags[];
activeId: string | null;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={`flex flex-col rounded-xl border p-4 transition-colors ${
isOver ? "border-primary bg-primary/5" : "border-border bg-muted/60"
} min-h-[240px]`}
>
<div className={`flex items-center justify-between pb-2 mb-2 border-b border-border ${headerClass}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
</div>
<span className="text-xs font-semibold tabular-nums bg-card rounded-full px-2 py-0.5 border border-border text-muted-foreground">
{leads.length}
</span>
</div>
<div className="flex-1 space-y-2">
{leads.map((lead) => (
<DraggableLeadCard key={lead.id} lead={lead} status={id} isActive={activeId === lead.id} />
))}
{leads.length === 0 && (
<p className="text-xs text-muted-foreground italic text-center py-10 select-none">
Nessun lead
</p>
)}
</div>
</div>
);
}
function DraggableLeadCard({
lead,
status,
isActive,
}: {
lead: LeadWithTags;
status: LeadStage;
isActive: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id });
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`bg-card rounded-lg border border-border px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-all duration-200 ${
isDragging ? "opacity-30" : "hover:border-primary/30 hover:shadow-card-hover"
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground">{lead.name}</p>
<StatusBadge status={status} className="rounded px-1.5 py-0.5 text-[9px]" />
</div>
{lead.email && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{lead.email}</p>
)}
{lead.company && (
<p className="text-xs text-muted-foreground mt-1">{lead.company}</p>
)}
{lead.next_action && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{lead.next_action}</p>
)}
</div>
);
}
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [activeId, setActiveId] = useState<string | null>(null);
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(() =>
Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
const leadsByStage = LEAD_COLUMNS.reduce(
(acc, col) => {
acc[col.id] = leads.filter((l) => (leadStatuses[l.id] ?? l.status) === col.id);
return acc;
},
{} as Record<LeadStage, LeadWithTags[]>
);
const activeLead = activeId ? leads.find((l) => l.id === activeId) : null;
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const leadId = active.id as string;
const newStage = over.id as LeadStage;
const currentStage = leadStatuses[leadId];
if (!VALID_STAGES.includes(newStage)) return;
if (newStage === currentStage) return;
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
startTransition(async () => {
await updateLeadField(leadId, "status", newStage);
router.refresh();
});
}
return (
<DndContext
sensors={sensors}
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="overflow-x-auto">
<div className="grid grid-cols-6 gap-3 min-w-[1080px]">
{LEAD_COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
{...col}
leads={leadsByStage[col.id]}
activeId={activeId}
/>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeLead && (
<div className="bg-card rounded-lg border-2 border-primary px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
<p className="text-sm font-medium text-foreground">{activeLead.name}</p>
{activeLead.company && (
<p className="text-xs text-muted-foreground">{activeLead.company}</p>
)}
</div>
)}
</DragOverlay>
</DndContext>
);
}