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>
This commit is contained in:
444
docs/03-frontend/kanban.md
Normal file
444
docs/03-frontend/kanban.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 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>
|
||||
)
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user