Files
aiworker/docs/03-frontend/kanban.md
Hector Ros db71705842 Complete documentation for future sessions
- CLAUDE.md for AI agents to understand the codebase
- GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth)
- DEVELOPMENT-WORKFLOW.md explains complete dev process
- ROADMAP.md, NEXT-SESSION.md for planning
- QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use
- 40+ detailed docs in /docs folder
- Backend as submodule from Gitea

Everything documented for autonomous operation.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 00:37:19 +01:00

12 KiB

Kanban Board - Implementación Detallada

Drag & Drop con dnd-kit

Configuración del DndContext

// components/kanban/KanbanBoard.tsx
import {
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragStartEvent,
  PointerSensor,
  useSensor,
  useSensors,
  DragOverlay,
} from '@dnd-kit/core'
import { useState } from 'react'

export function KanbanBoard({ projectId }: KanbanBoardProps) {
  const [activeId, setActiveId] = useState<string | null>(null)
  const { data: tasks = [] } = useTasks({ projectId })
  const updateTask = useUpdateTask()

  // Configure sensors
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8, // Require 8px movement before dragging starts
      },
    })
  )

  const handleDragStart = (event: DragStartEvent) => {
    setActiveId(event.active.id as string)
  }

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event

    setActiveId(null)

    if (!over || active.id === over.id) return

    const taskId = active.id as string
    const newState = over.id as TaskState

    // Optimistic update
    updateTask.mutate({
      taskId,
      updates: { state: newState },
    })
  }

  const activeTask = tasks.find((t) => t.id === activeId)

  return (
    <DndContext
      sensors={sensors}
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 overflow-x-auto pb-4">
        {COLUMNS.map((column) => (
          <KanbanColumn
            key={column.id}
            id={column.id}
            title={column.title}
            color={column.color}
            tasks={tasksByState[column.id]}
          />
        ))}
      </div>

      {/* Drag overlay for better UX */}
      <DragOverlay>
        {activeTask ? <TaskCard task={activeTask} /> : null}
      </DragOverlay>
    </DndContext>
  )
}

Column como Droppable

// components/kanban/KanbanColumn.tsx
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'

export default function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
  const { setNodeRef, isOver } = useDroppable({ id })

  return (
    <div className="flex flex-col w-80 flex-shrink-0">
      {/* Header */}
      <div className={`bg-${color}-100 border-${color}-300 border-t-4 rounded-t-lg p-3`}>
        <h3 className="font-semibold text-gray-900">
          {title}
          <span className="ml-2 text-sm text-gray-500">({tasks.length})</span>
        </h3>
      </div>

      {/* Drop zone */}
      <div
        ref={setNodeRef}
        className={`
          flex-1 bg-gray-50 border border-t-0 border-gray-200 rounded-b-lg p-3 min-h-[200px]
          ${isOver ? 'bg-blue-50 border-blue-300' : ''}
          transition-colors
        `}
      >
        <SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
          <div className="space-y-3">
            {tasks.map((task) => (
              <TaskCard key={task.id} task={task} />
            ))}
          </div>
        </SortableContext>

        {tasks.length === 0 && (
          <div className="text-center text-gray-400 text-sm py-8">
            {isOver ? 'Suelta aquí' : 'Sin tareas'}
          </div>
        )}
      </div>
    </div>
  )
}

Task Card como Draggable

// components/kanban/TaskCard.tsx
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

export default function TaskCard({ task }: TaskCardProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id: task.id,
    data: {
      type: 'task',
      task,
    },
  })

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
    cursor: 'move',
  }

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="card hover:shadow-md transition-shadow"
    >
      {/* Task content */}
    </div>
  )
}

Acciones Rápidas

// components/kanban/TaskCardActions.tsx
import { MoreVertical, ExternalLink, MessageSquare, CheckCircle, XCircle } from 'lucide-react'
import { Task } from '@/types/task'
import { useApproveTask, useRejectTask } from '@/hooks/useTasks'

interface TaskCardActionsProps {
  task: Task
}

export function TaskCardActions({ task }: TaskCardActionsProps) {
  const approveTask = useApproveTask()
  const rejectTask = useRejectTask()

  const handleApprove = (e: React.MouseEvent) => {
    e.stopPropagation()
    if (confirm('¿Aprobar esta tarea?')) {
      approveTask.mutate(task.id)
    }
  }

  const handleReject = (e: React.MouseEvent) => {
    e.stopPropagation()
    const reason = prompt('Razón del rechazo:')
    if (reason) {
      rejectTask.mutate({ taskId: task.id, reason })
    }
  }

  return (
    <div className="flex items-center gap-1">
      {/* Preview link */}
      {task.previewUrl && (
        <a
          href={task.previewUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="p-1 hover:bg-gray-100 rounded"
          onClick={(e) => e.stopPropagation()}
          title="Abrir preview"
        >
          <ExternalLink className="w-4 h-4 text-gray-600" />
        </a>
      )}

      {/* Questions */}
      {task.state === 'needs_input' && (
        <button
          className="p-1 hover:bg-yellow-100 rounded"
          title="Responder pregunta"
        >
          <MessageSquare className="w-4 h-4 text-yellow-600" />
        </button>
      )}

      {/* Approve/Reject for ready_to_test */}
      {task.state === 'ready_to_test' && (
        <>
          <button
            onClick={handleApprove}
            className="p-1 hover:bg-green-100 rounded"
            title="Aprobar"
          >
            <CheckCircle className="w-4 h-4 text-green-600" />
          </button>
          <button
            onClick={handleReject}
            className="p-1 hover:bg-red-100 rounded"
            title="Rechazar"
          >
            <XCircle className="w-4 h-4 text-red-600" />
          </button>
        </>
      )}

      {/* More actions */}
      <button className="p-1 hover:bg-gray-100 rounded">
        <MoreVertical className="w-4 h-4 text-gray-600" />
      </button>
    </div>
  )
}

Filtros y Búsqueda

// components/kanban/KanbanFilters.tsx
import { useState } from 'react'
import { Search, Filter } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'

interface KanbanFiltersProps {
  onFilterChange: (filters: TaskFilters) => void
}

export function KanbanFilters({ onFilterChange }: KanbanFiltersProps) {
  const [search, setSearch] = useState('')
  const [priority, setPriority] = useState<string>('all')
  const [assignedAgent, setAssignedAgent] = useState<string>('all')

  const handleSearchChange = (value: string) => {
    setSearch(value)
    onFilterChange({ search: value, priority, assignedAgent })
  }

  return (
    <div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm mb-4">
      <div className="flex-1 relative">
        <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
        <input
          type="text"
          value={search}
          onChange={(e) => handleSearchChange(e.target.value)}
          placeholder="Buscar tareas..."
          className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
        />
      </div>

      <Select
        value={priority}
        onChange={(e) => {
          setPriority(e.target.value)
          onFilterChange({ search, priority: e.target.value, assignedAgent })
        }}
        options={[
          { value: 'all', label: 'Todas las prioridades' },
          { value: 'urgent', label: 'Urgente' },
          { value: 'high', label: 'Alta' },
          { value: 'medium', label: 'Media' },
          { value: 'low', label: 'Baja' },
        ]}
        className="w-48"
      />

      <button className="btn-secondary">
        <Filter className="w-4 h-4 mr-2" />
        Más filtros
      </button>
    </div>
  )
}

Bulk Actions

// components/kanban/KanbanBulkActions.tsx
import { useState } from 'react'
import { CheckSquare, GitMerge, Trash2 } from 'lucide-react'
import { Task } from '@/types/task'

interface KanbanBulkActionsProps {
  selectedTasks: Task[]
  onMergeToStaging: (taskIds: string[]) => void
  onClearSelection: () => void
}

export function KanbanBulkActions({
  selectedTasks,
  onMergeToStaging,
  onClearSelection,
}: KanbanBulkActionsProps) {
  if (selectedTasks.length === 0) return null

  const approvedTasks = selectedTasks.filter((t) => t.state === 'approved')

  return (
    <div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 p-4">
      <div className="flex items-center gap-4">
        <div className="flex items-center gap-2">
          <CheckSquare className="w-5 h-5 text-primary-600" />
          <span className="font-medium">
            {selectedTasks.length} tarea{selectedTasks.length !== 1 ? 's' : ''} seleccionada{selectedTasks.length !== 1 ? 's' : ''}
          </span>
        </div>

        <div className="h-6 w-px bg-gray-300" />

        {approvedTasks.length >= 2 && (
          <button
            onClick={() => onMergeToStaging(approvedTasks.map((t) => t.id))}
            className="btn-primary flex items-center gap-2"
          >
            <GitMerge className="w-4 h-4" />
            Merge a Staging ({approvedTasks.length})
          </button>
        )}

        <button onClick={onClearSelection} className="btn-secondary">
          Limpiar selección
        </button>
      </div>
    </div>
  )
}

Estadísticas del Kanban

// components/kanban/KanbanStats.tsx
import { Task } from '@/types/task'
import { Activity, CheckCircle, Clock, AlertTriangle } from 'lucide-react'

interface KanbanStatsProps {
  tasks: Task[]
}

export function KanbanStats({ tasks }: KanbanStatsProps) {
  const stats = {
    total: tasks.length,
    inProgress: tasks.filter((t) => t.state === 'in_progress').length,
    completed: tasks.filter((t) => t.state === 'production').length,
    needsInput: tasks.filter((t) => t.state === 'needs_input').length,
    avgDuration: tasks
      .filter((t) => t.actualDurationMinutes)
      .reduce((acc, t) => acc + (t.actualDurationMinutes || 0), 0) / tasks.length || 0,
  }

  return (
    <div className="grid grid-cols-4 gap-4 mb-6">
      <div className="card">
        <div className="flex items-center justify-between">
          <div>
            <p className="text-sm text-gray-600">Total</p>
            <p className="text-2xl font-bold text-gray-900">{stats.total}</p>
          </div>
          <Activity className="w-8 h-8 text-gray-400" />
        </div>
      </div>

      <div className="card">
        <div className="flex items-center justify-between">
          <div>
            <p className="text-sm text-gray-600">En Progreso</p>
            <p className="text-2xl font-bold text-blue-600">{stats.inProgress}</p>
          </div>
          <Clock className="w-8 h-8 text-blue-400" />
        </div>
      </div>

      <div className="card">
        <div className="flex items-center justify-between">
          <div>
            <p className="text-sm text-gray-600">Completadas</p>
            <p className="text-2xl font-bold text-green-600">{stats.completed}</p>
          </div>
          <CheckCircle className="w-8 h-8 text-green-400" />
        </div>
      </div>

      <div className="card">
        <div className="flex items-center justify-between">
          <div>
            <p className="text-sm text-gray-600">Necesitan Input</p>
            <p className="text-2xl font-bold text-yellow-600">{stats.needsInput}</p>
          </div>
          <AlertTriangle className="w-8 h-8 text-yellow-400" />
        </div>
      </div>
    </div>
  )
}