2c67e6fd72
- 6-column Kanban board: contacted, qualified, proposal_sent, negotiating, won, lost
- DroppableColumn with useDroppable, isOver highlight (border-[#1A463C])
- DraggableLeadCard with useDraggable, opacity-30 on drag, name/company/next_action
- Optimistic update via setLeadStatuses + startTransition + updateLeadField + router.refresh
- Client-side guard: VALID_STAGES check before calling server action (T-19-01)
- DragOverlay dropAnimation={null} ghost card with rotate-1 shadow-xl
- overflow-x-auto wrapper with grid-cols-6 min-w-[1080px]
184 lines
5.9 KiB
TypeScript
184 lines
5.9 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/leads/actions";
|
|
import type { LeadWithTags } from "@/lib/admin-queries";
|
|
|
|
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-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
|
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
|
|
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
|
|
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
|
|
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
|
|
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
|
|
];
|
|
|
|
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-2 transition-colors ${
|
|
isOver ? "border-[#1A463C] bg-[#1A463C]/5" : "border-[#e5e7eb] bg-[#f9f9f9]"
|
|
} min-h-[240px]`}
|
|
>
|
|
<div className={`px-4 py-3 flex items-center justify-between ${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-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
|
{leads.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 p-3 space-y-2">
|
|
{leads.map((lead) => (
|
|
<DraggableLeadCard key={lead.id} lead={lead} isActive={activeId === lead.id} />
|
|
))}
|
|
{leads.length === 0 && (
|
|
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">
|
|
Nessun lead
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; 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-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-opacity ${
|
|
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow"
|
|
}`}
|
|
>
|
|
<p className="text-sm font-medium text-[#1a1a1a]">{lead.name}</p>
|
|
{lead.company && (
|
|
<p className="text-xs text-[#71717a]">{lead.company}</p>
|
|
)}
|
|
{lead.next_action && (
|
|
<p className="text-xs text-[#71717a] 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-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
|
|
<p className="text-sm font-medium text-[#1a1a1a]">{activeLead.name}</p>
|
|
{activeLead.company && (
|
|
<p className="text-xs text-[#71717a]">{activeLead.company}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|