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:
Hector Ros
2026-01-20 00:36:53 +01:00
commit db71705842
49 changed files with 19162 additions and 0 deletions

444
docs/03-frontend/kanban.md Normal file
View 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>
)
}
```