103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
|
|
/** Columna genérica del kanban */
|
|
interface ColumnaDef {
|
|
id: string
|
|
titulo: string
|
|
color: string
|
|
}
|
|
|
|
interface KanbanViewProps<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }> {
|
|
items: T[]
|
|
columnas: ColumnaDef[]
|
|
obtenerColumna: (item: T) => string
|
|
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
|
|
renderCard?: (item: T) => React.ReactNode
|
|
/** Click en la tarjeta: abre la bitácora/detalle del registro */
|
|
onVerItem?: (item: T) => void
|
|
}
|
|
|
|
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
|
|
items,
|
|
columnas,
|
|
obtenerColumna,
|
|
onCambiarEstado,
|
|
renderCard,
|
|
onVerItem,
|
|
}: KanbanViewProps<T>) {
|
|
const [arrastrando, setArrastrando] = useState<string | null>(null)
|
|
|
|
const onDragStart = useCallback((e: React.DragEvent, itemId: string) => {
|
|
e.dataTransfer.setData('text/plain', itemId)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
setArrastrando(itemId)
|
|
}, [])
|
|
|
|
const onDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
}, [])
|
|
|
|
const onDrop = useCallback((e: React.DragEvent, columnaId: string) => {
|
|
e.preventDefault()
|
|
const itemId = e.dataTransfer.getData('text/plain')
|
|
setArrastrando(null)
|
|
if (itemId === '') return
|
|
const item = items.find((i) => i.id === itemId)
|
|
if (!item || obtenerColumna(item) === columnaId) return
|
|
onCambiarEstado(itemId, columnaId)
|
|
}, [items, obtenerColumna, onCambiarEstado])
|
|
|
|
return (
|
|
<div className="flex gap-3 overflow-x-auto pb-4 scroll-smooth">
|
|
{columnas.map((col) => {
|
|
const itemsCol = items.filter((item) => obtenerColumna(item) === col.id)
|
|
return (
|
|
<div
|
|
key={col.id}
|
|
className={`flex min-w-[220px] flex-1 flex-col rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] border-t-4 ${col.color}`}
|
|
onDragOver={onDragOver}
|
|
onDrop={(e) => void onDrop(e, col.id)}
|
|
>
|
|
<div className="flex items-center justify-between px-3 pt-3 pb-2 sm:px-4">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
{col.titulo}
|
|
</h3>
|
|
<span className="rounded-full bg-[#E5E7EB] px-2 py-0.5 text-[10px] font-medium text-[#64748B] dark:bg-[#334155] dark:text-[#94A3B8]">
|
|
{itemsCol.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-2 px-2 pb-3 sm:px-3">
|
|
{itemsCol.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
draggable
|
|
onDragStart={(e) => void onDragStart(e, item.id)}
|
|
onClick={onVerItem ? () => onVerItem(item) : undefined}
|
|
role={onVerItem ? 'button' : undefined}
|
|
tabIndex={onVerItem ? 0 : undefined}
|
|
onKeyDown={onVerItem ? (e) => { if (e.key === 'Enter') onVerItem(item) } : undefined}
|
|
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''} ${onVerItem ? 'cursor-pointer hover:border-accent' : ''}`}
|
|
>
|
|
{renderCard ? renderCard(item) : (
|
|
<>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
|
</div>
|
|
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
|
|
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
{itemsCol.length === 0 && (
|
|
<p className="py-8 text-center text-xs text-[#94A3B8] dark:text-[#64748B]">Sin items</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|