|
"use client"; |
|
|
|
import { |
|
DndContext, |
|
DragEndEvent, |
|
DragOverEvent, |
|
DragOverlay, |
|
DragStartEvent, |
|
PointerSensor, |
|
closestCorners, |
|
pointerWithin, |
|
useDroppable, |
|
useSensor, |
|
useSensors, |
|
} from "@dnd-kit/core"; |
|
import { Lead, User } from "@payload-types"; |
|
import React, { useCallback, useRef, useState } from "react"; |
|
import { |
|
SortableContext, |
|
arrayMove, |
|
useSortable, |
|
verticalListSortingStrategy, |
|
} from "@dnd-kit/sortable"; |
|
|
|
import { CSS } from "@dnd-kit/utilities"; |
|
import Link from "next/link"; |
|
import type { PaginatedDocs } from "payload"; |
|
import { SelectInput } from "@payloadcms/ui"; |
|
import { UserAvatar } from "@/components/admin/avatar"; |
|
import { cn } from "@heroui/react"; |
|
import { generateKeyBetween } from "payload/shared"; |
|
import { sdk } from "@/lib/payload/sdk"; |
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────── |
|
|
|
type LeadStatus = NonNullable<Lead["status"]>; |
|
|
|
type ColumnDef = { |
|
id: LeadStatus; |
|
label: string; |
|
pillBg: string; |
|
pillText: string; |
|
}; |
|
|
|
// Type alias — no need to redefine what PaginatedDocs already provides |
|
type ColumnState = PaginatedDocs<Lead>; |
|
|
|
// ─── Column definitions ─────────────────────────────────────────────────────── |
|
|
|
const COLUMNS: ColumnDef[] = [ |
|
{ |
|
id: "new", |
|
label: "Neu", |
|
pillBg: "bg-blue-500/10", |
|
pillText: "text-blue-400", |
|
}, |
|
{ |
|
id: "contact", |
|
label: "Kontakt", |
|
pillBg: "bg-violet-500/10", |
|
pillText: "text-violet-400", |
|
}, |
|
{ |
|
id: "wait", |
|
label: "Abwarten", |
|
pillBg: "bg-amber-500/10", |
|
pillText: "text-amber-400", |
|
}, |
|
{ |
|
id: "converted", |
|
label: "Konvertiert", |
|
pillBg: "bg-emerald-500/10", |
|
pillText: "text-emerald-400", |
|
}, |
|
{ |
|
id: "declined", |
|
label: "Abgelehnt", |
|
pillBg: "bg-red-500/10", |
|
pillText: "text-red-400", |
|
}, |
|
]; |
|
|
|
// ─── API helpers ────────────────────────────────────────────────────────────── |
|
|
|
async function updateLead( |
|
leadId: number, |
|
data: { status?: LeadStatus; _order?: string }, |
|
): Promise<void> { |
|
await sdk.update({ collection: "leads", id: leadId, data }); |
|
} |
|
|
|
async function fetchMoreLeads( |
|
status: LeadStatus, |
|
page: number, |
|
): Promise<ColumnState> { |
|
return sdk.find({ |
|
collection: "leads", |
|
where: { status: { equals: status } }, |
|
limit: 10, |
|
page, |
|
}) as Promise<ColumnState>; |
|
} |
|
|
|
// ─── Fractional indexing ────────────────────────────────────────────────────── |
|
|
|
function orderKeyForIndex(docs: Lead[], index: number): string { |
|
const prev = (docs[index - 1]?._order ?? null) as string | null; |
|
const next = (docs[index + 1]?._order ?? null) as string | null; |
|
return generateKeyBetween(prev, next); |
|
} |
|
|
|
// ─── Misc helpers ───────────────────────────────────────────────────────────── |
|
|
|
function isColumnId(id: string | number): id is LeadStatus { |
|
return typeof id === "string" && COLUMNS.some((c) => c.id === id); |
|
} |
|
|
|
// Given an over.id (either a column string or a lead number), return the column it belongs to |
|
function resolveColumn( |
|
overId: string | number, |
|
columns: Record<LeadStatus, ColumnState>, |
|
): LeadStatus | null { |
|
if (isColumnId(overId)) return overId; |
|
for (const col of COLUMNS) { |
|
if (columns[col.id].docs.some((l) => l.id === overId)) return col.id; |
|
} |
|
return null; |
|
} |
|
|
|
// ─── Assignee select ────────────────────────────────────────────────────────── |
|
|
|
function AssigneeSelect({ |
|
leadId, |
|
value, |
|
users, |
|
onChange, |
|
}: { |
|
leadId: number; |
|
value: number | null; |
|
users: User[]; |
|
onChange: (userId: number | null) => void; |
|
}) { |
|
const options = [ |
|
{ label: "Nicht zugewiesen", value: "" }, |
|
...users.map((u) => ({ |
|
label: [u.firstName, u.lastName].filter(Boolean).join(" ") || u.email, |
|
value: String(u.id), |
|
})), |
|
]; |
|
|
|
const path = `assignee-${leadId}`; |
|
|
|
return ( |
|
// Stop pointer events from bubbling to the drag listeners on the card |
|
<div |
|
onClick={(e) => e.stopPropagation()} |
|
onPointerDown={(e) => e.stopPropagation()} |
|
> |
|
<SelectInput |
|
path={path} |
|
name={path} |
|
options={options} |
|
value={value ? String(value) : ""} |
|
onChange={(opt) => { |
|
if (Array.isArray(opt)) return; |
|
const next = opt?.value ? Number(opt.value) : null; |
|
onChange(next); |
|
updateLead(leadId, { assignee: next ?? undefined } as any).catch( |
|
console.error, |
|
); |
|
}} |
|
/> |
|
</div> |
|
); |
|
} |
|
|
|
// ─── Lead card ──────────────────────────────────────────────────────────────── |
|
|
|
function LeadCard({ |
|
lead, |
|
users, |
|
isDragging = false, |
|
listeners, |
|
attributes, |
|
setNodeRef, |
|
style, |
|
onAssigneeChange, |
|
}: { |
|
lead: Lead; |
|
users: User[]; |
|
isDragging?: boolean; |
|
listeners?: ReturnType<typeof useSortable>["listeners"]; |
|
attributes?: ReturnType<typeof useSortable>["attributes"]; |
|
setNodeRef?: (node: HTMLElement | null) => void; |
|
style?: React.CSSProperties; |
|
onAssigneeChange?: (userId: number | null) => void; |
|
}) { |
|
const fullName = |
|
[lead.firstName, lead.lastName].filter(Boolean).join(" ") || "—"; |
|
const location = [lead.address?.zip, lead.address?.area] |
|
.filter(Boolean) |
|
.join(" "); |
|
|
|
const assigneeId = |
|
typeof lead.assignee === "object" && lead.assignee !== null |
|
? (lead.assignee as User).id |
|
: ((lead.assignee as number | null) ?? null); |
|
|
|
const assigneeUser = assigneeId |
|
? (users.find((u) => u.id === assigneeId) ?? null) |
|
: null; |
|
|
|
return ( |
|
<div |
|
ref={setNodeRef} |
|
style={style} |
|
{...attributes} |
|
{...listeners} |
|
className={cn( |
|
"card flex-col gap-4", |
|
"cursor-grab active:cursor-grabbing select-none", |
|
"transition-opacity duration-150", |
|
isDragging ? "opacity-30" : "opacity-100", |
|
)} |
|
> |
|
{/* Name + gender */} |
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> |
|
<p |
|
className={cn( |
|
"text-sm font-semibold leading-snug", |
|
"text-(--theme-elevation-1000)", |
|
)} |
|
> |
|
{fullName} |
|
</p> |
|
{lead.sex && ( |
|
<span className={cn("text-xs", "text-(--theme-elevation-400)")}> |
|
{lead.sex === "male" ? "Herr" : "Frau"} |
|
</span> |
|
)} |
|
</div> |
|
|
|
{(location || lead.birthdate) && ( |
|
<div className="text-xs text-(--theme-elevation-400) space-y-1"> |
|
{location && <p>{location}</p>} |
|
|
|
{lead.birthdate && ( |
|
<p> |
|
{new Date(lead.birthdate).toLocaleDateString("de-DE", { |
|
day: "numeric", |
|
month: "short", |
|
year: "numeric", |
|
})} |
|
</p> |
|
)} |
|
</div> |
|
)} |
|
|
|
{/* Assignee row */} |
|
<div className="flex items-center gap-2"> |
|
{assigneeUser && <UserAvatar user={assigneeUser} size={20} />} |
|
<div className="flex-1 min-w-0"> |
|
<AssigneeSelect |
|
leadId={lead.id} |
|
value={assigneeId} |
|
users={users} |
|
onChange={onAssigneeChange ?? (() => {})} |
|
/> |
|
</div> |
|
</div> |
|
|
|
<Link |
|
href={`/admin/collections/leads/${lead.id}`} |
|
onClick={(e) => e.stopPropagation()} |
|
className="text-xs inline-block" |
|
> |
|
Öffnen → |
|
</Link> |
|
</div> |
|
); |
|
} |
|
|
|
// ─── Sortable wrapper ───────────────────────────────────────────────────────── |
|
|
|
function SortableLeadCard({ |
|
lead, |
|
users, |
|
onAssigneeChange, |
|
}: { |
|
lead: Lead; |
|
users: User[]; |
|
onAssigneeChange: (userId: number | null) => void; |
|
}) { |
|
const { |
|
attributes, |
|
listeners, |
|
setNodeRef, |
|
transform, |
|
transition, |
|
isDragging, |
|
} = useSortable({ id: lead.id, data: { type: "lead", lead } }); |
|
|
|
return ( |
|
<LeadCard |
|
lead={lead} |
|
users={users} |
|
isDragging={isDragging} |
|
setNodeRef={setNodeRef} |
|
style={{ transform: CSS.Transform.toString(transform), transition }} |
|
listeners={listeners} |
|
attributes={attributes} |
|
onAssigneeChange={onAssigneeChange} |
|
/> |
|
); |
|
} |
|
|
|
// ─── Column ─────────────────────────────────────────────────────────────────── |
|
|
|
function KanbanColumn({ |
|
column, |
|
state, |
|
users, |
|
isOver, |
|
onLoadMore, |
|
onAssigneeChange, |
|
}: { |
|
column: ColumnDef; |
|
state: ColumnState; |
|
users: User[]; |
|
isOver: boolean; |
|
onLoadMore: () => void; |
|
onAssigneeChange: (leadId: number, userId: number | null) => void; |
|
}) { |
|
const ids = state.docs.map((l) => l.id); |
|
const { setNodeRef } = useDroppable({ id: column.id }); |
|
|
|
return ( |
|
<div className="flex flex-col w-80 shrink-0 h-full"> |
|
<div className="flex items-center justify-between mb-2 px-1"> |
|
<span |
|
className={cn( |
|
"text-sm font-semibold", |
|
"text-(--theme-elevation-800)", |
|
)} |
|
> |
|
{column.label} |
|
</span> |
|
<span |
|
className={cn( |
|
"text-xs font-mono px-2 py-0.5 rounded", |
|
column.pillBg, |
|
column.pillText, |
|
)} |
|
> |
|
{state.totalDocs} |
|
</span> |
|
</div> |
|
|
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}> |
|
<div |
|
ref={setNodeRef} |
|
className={cn( |
|
"flex flex-1 flex-col gap-2 min-h-20 p-2 rounded border transition-colors duration-150", |
|
"border-(--theme-border-color) bg-(--theme-elevation-50)", |
|
isOver && "bg-(--theme-success-50) border-(--theme-success-200)", |
|
)} |
|
> |
|
{ids.length === 0 ? ( |
|
<div |
|
className={cn( |
|
"flex items-center justify-center h-15 text-xs italic", |
|
"text-(--theme-elevation-350)", |
|
isOver && "text-(--theme-success-500)", |
|
)} |
|
> |
|
{isOver ? "Hier ablegen" : "Keine Leads"} |
|
</div> |
|
) : ( |
|
state.docs.map((lead) => ( |
|
<SortableLeadCard |
|
key={lead.id} |
|
lead={lead} |
|
users={users} |
|
onAssigneeChange={(userId) => onAssigneeChange(lead.id, userId)} |
|
/> |
|
)) |
|
)} |
|
</div> |
|
</SortableContext> |
|
|
|
{state.hasNextPage && ( |
|
<button |
|
onClick={onLoadMore} |
|
className={cn( |
|
"mt-2 w-full text-xs py-1.5 rounded border transition-colors duration-150", |
|
"border-(--theme-border-color) text-(--theme-elevation-500)", |
|
"hover:bg-(--theme-elevation-100) hover:text-(--theme-elevation-800)", |
|
)} |
|
> |
|
Mehr laden ({state.docs.length} / {state.totalDocs}) |
|
</button> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
// ─── Main board ─────────────────────────────────────────────────────────────── |
|
|
|
export type KanbanProps = { |
|
initialColumns: Record<LeadStatus, PaginatedDocs<Lead>>; |
|
users: User[]; |
|
className?: string; |
|
}; |
|
|
|
export function Kanban({ initialColumns, users, className }: KanbanProps) { |
|
const [columns, setColumns] = useState<Record<LeadStatus, ColumnState>>( |
|
() => ({ ...initialColumns }), |
|
); |
|
|
|
const [activeLead, setActiveLead] = useState<Lead | null>(null); |
|
|
|
// overColumn: stored in a ref for synchronous reads + state for re-renders. |
|
// We write the ref first so the isDragging guard in onDragOver can see the |
|
// cleared value before React has flushed anything. |
|
const overColumnRef = useRef<LeadStatus | null>(null); |
|
const [overColumn, setOverColumnState] = useState<LeadStatus | null>(null); |
|
|
|
const setOverColumn = useCallback((col: LeadStatus | null) => { |
|
overColumnRef.current = col; |
|
setOverColumnState(col); |
|
}, []); |
|
|
|
// isDragging ref: set false at the very top of handleDragEnd so that the |
|
// spurious final onDragOver DnD-Kit emits after drop is ignored completely. |
|
const isDragging = useRef(false); |
|
|
|
// sourceCol: locked at dragStart, never mutated during the drag. |
|
// We do NOT do cross-column optimistic moves in onDragOver — only in onDragEnd. |
|
// This is what allows dragging across multiple columns: the card always lives |
|
// in its original column in state until the drag ends. |
|
const sourceCol = useRef<LeadStatus | null>(null); |
|
const activeId = useRef<number | null>(null); |
|
|
|
const sensors = useSensors( |
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), |
|
); |
|
|
|
// ── Assignee change ─────────────────────────────────────────────────────── |
|
|
|
const handleAssigneeChange = useCallback( |
|
(leadId: number, userId: number | null) => { |
|
setColumns((prev) => { |
|
const entries = Object.entries(prev) as [LeadStatus, ColumnState][]; |
|
for (const [colId, state] of entries) { |
|
const idx = state.docs.findIndex((l) => l.id === leadId); |
|
if (idx < 0) continue; |
|
const updatedDocs = [...state.docs]; |
|
updatedDocs[idx] = { ...updatedDocs[idx], assignee: userId as any }; |
|
return { ...prev, [colId]: { ...state, docs: updatedDocs } }; |
|
} |
|
return prev; |
|
}); |
|
}, |
|
[], |
|
); |
|
|
|
// ── Load more ───────────────────────────────────────────────────────────── |
|
|
|
const handleLoadMore = useCallback( |
|
async (status: LeadStatus) => { |
|
const nextPage = (columns[status].page ?? 1) + 1; |
|
const result = await fetchMoreLeads(status, nextPage); |
|
setColumns((prev) => ({ |
|
...prev, |
|
[status]: { |
|
...result, |
|
docs: [...prev[status].docs, ...result.docs], |
|
}, |
|
})); |
|
}, |
|
[columns], |
|
); |
|
|
|
// ── DnD handlers ───────────────────────────────────────────────────────── |
|
|
|
const handleDragStart = useCallback(({ active }: DragStartEvent) => { |
|
isDragging.current = true; |
|
activeId.current = active.id as number; |
|
|
|
// Snapshot source column and active lead |
|
setColumns((prev) => { |
|
for (const col of COLUMNS) { |
|
const lead = prev[col.id].docs.find((l) => l.id === active.id); |
|
if (lead) { |
|
sourceCol.current = col.id; |
|
setActiveLead(lead); |
|
break; |
|
} |
|
} |
|
return prev; |
|
}); |
|
}, []); |
|
|
|
const handleDragOver = useCallback( |
|
({ over }: DragOverEvent) => { |
|
// If drag has ended, ignore — DnD-Kit fires one last dragOver after dragEnd |
|
if (!isDragging.current) return; |
|
|
|
if (!over) { |
|
setOverColumn(null); |
|
return; |
|
} |
|
|
|
// Only update the highlight — no state moves here. |
|
// Don't highlight the source column itself, only foreign ones. |
|
setColumns((prev) => { |
|
const col = resolveColumn(over.id as string | number, prev); |
|
setOverColumn(col !== sourceCol.current ? col : null); |
|
return prev; |
|
}); |
|
}, |
|
[setOverColumn], |
|
); |
|
|
|
const handleDragEnd = useCallback( |
|
({ active, over }: DragEndEvent) => { |
|
// Must be first — kills the spurious final onDragOver |
|
isDragging.current = false; |
|
|
|
setActiveLead(null); |
|
setOverColumn(null); |
|
|
|
const draggedId = active.id as number; |
|
const from = sourceCol.current; |
|
sourceCol.current = null; |
|
activeId.current = null; |
|
|
|
if (!over || !from) return; |
|
|
|
const overId = over.id as string | number; |
|
|
|
setColumns((prev) => { |
|
const to = resolveColumn(overId, prev); |
|
if (!to) return prev; |
|
|
|
if (from === to) { |
|
// ── Within-column reorder ────────────────────────────────────────── |
|
// over.id is a card id (number), not a column id |
|
if (isColumnId(overId)) return prev; |
|
|
|
const docs = prev[from].docs; |
|
const oldIdx = docs.findIndex((l) => l.id === draggedId); |
|
const newIdx = docs.findIndex((l) => l.id === overId); |
|
if (oldIdx < 0 || newIdx < 0 || oldIdx === newIdx) return prev; |
|
|
|
const reordered = arrayMove(docs, oldIdx, newIdx); |
|
const newOrder = orderKeyForIndex(reordered, newIdx); |
|
const withOrder = reordered.map((l, i) => |
|
i === newIdx ? { ...l, _order: newOrder } : l, |
|
); |
|
|
|
updateLead(draggedId, { _order: newOrder }).catch(console.error); |
|
|
|
return { ...prev, [from]: { ...prev[from], docs: withOrder } }; |
|
} else { |
|
// ── Cross-column move ────────────────────────────────────────────── |
|
const lead = prev[from].docs.find((l) => l.id === draggedId); |
|
if (!lead) return prev; |
|
|
|
// Remove from source |
|
const newFromDocs = prev[from].docs.filter((l) => l.id !== draggedId); |
|
|
|
// Insert into target: if over a card place before it, otherwise append |
|
const toDocs = [...prev[to].docs]; |
|
const overIdx = isColumnId(overId) |
|
? toDocs.length // dropped on empty column |
|
: toDocs.findIndex((l) => l.id === overId); // dropped on a card |
|
const insertIdx = overIdx >= 0 ? overIdx : toDocs.length; |
|
toDocs.splice(insertIdx, 0, { ...lead, status: to }); |
|
|
|
const newOrder = orderKeyForIndex(toDocs, insertIdx); |
|
toDocs[insertIdx] = { ...toDocs[insertIdx], _order: newOrder }; |
|
|
|
updateLead(draggedId, { status: to, _order: newOrder }).catch( |
|
(err) => { |
|
console.error("Failed to update lead:", err); |
|
// TODO: roll back on failure |
|
}, |
|
); |
|
|
|
return { |
|
...prev, |
|
[from]: { |
|
...prev[from], |
|
docs: newFromDocs, |
|
totalDocs: prev[from].totalDocs - 1, |
|
}, |
|
[to]: { |
|
...prev[to], |
|
docs: toDocs, |
|
totalDocs: prev[to].totalDocs + 1, |
|
}, |
|
}; |
|
} |
|
}); |
|
}, |
|
[setOverColumn], |
|
); |
|
|
|
// ───────────────────────────────────────────────────────────────────────── |
|
|
|
return ( |
|
<div className={cn("w-full overflow-x-auto flex items-stretch", className)}> |
|
<DndContext |
|
sensors={sensors} |
|
collisionDetection={(args) => { |
|
const pointerHits = pointerWithin(args); |
|
return pointerHits.length > 0 ? pointerHits : closestCorners(args); |
|
}} |
|
onDragStart={handleDragStart} |
|
onDragOver={handleDragOver} |
|
onDragEnd={handleDragEnd} |
|
> |
|
<div className="flex gap-4 min-w-max items-stretch"> |
|
{COLUMNS.map((col) => ( |
|
<KanbanColumn |
|
key={col.id} |
|
column={col} |
|
state={columns[col.id]} |
|
isOver={activeLead !== null && overColumn === col.id} |
|
users={users} |
|
onLoadMore={() => handleLoadMore(col.id)} |
|
onAssigneeChange={handleAssigneeChange} |
|
/> |
|
))} |
|
</div> |
|
|
|
<DragOverlay dropAnimation={{ duration: 120, easing: "ease" }}> |
|
{activeLead && ( |
|
<div className="rotate-1 shadow-xl"> |
|
<LeadCard lead={activeLead} users={users} /> |
|
</div> |
|
)} |
|
</DragOverlay> |
|
</DndContext> |
|
</div> |
|
); |
|
} |