- 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>
445 lines
12 KiB
Markdown
445 lines
12 KiB
Markdown
# Kanban Board - Implementación Detallada
|
|
|
|
## Drag & Drop con dnd-kit
|
|
|
|
### Configuración del DndContext
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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>
|
|
)
|
|
}
|
|
```
|