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:
498
docs/03-frontend/componentes.md
Normal file
498
docs/03-frontend/componentes.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Componentes Principales
|
||||
|
||||
## KanbanBoard
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanBoard.tsx
|
||||
import { useMemo } from 'react'
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { useTasks, useUpdateTask } from '@/hooks/useTasks'
|
||||
import KanbanColumn from './KanbanColumn'
|
||||
import { Task, TaskState } from '@/types/task'
|
||||
|
||||
const COLUMNS: { id: TaskState; title: string; color: string }[] = [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'gray' },
|
||||
{ id: 'in_progress', title: 'En Progreso', color: 'blue' },
|
||||
{ id: 'needs_input', title: 'Necesita Respuestas', color: 'yellow' },
|
||||
{ id: 'ready_to_test', title: 'Listo para Probar', color: 'purple' },
|
||||
{ id: 'approved', title: 'Aprobado', color: 'green' },
|
||||
{ id: 'staging', title: 'Staging', color: 'indigo' },
|
||||
{ id: 'production', title: 'Producción', color: 'emerald' },
|
||||
]
|
||||
|
||||
interface KanbanBoardProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export function KanbanBoard({ projectId }: KanbanBoardProps) {
|
||||
const { data: tasks = [], isLoading } = useTasks({ projectId })
|
||||
const updateTask = useUpdateTask()
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const tasksByState = useMemo(() => {
|
||||
return COLUMNS.reduce((acc, column) => {
|
||||
acc[column.id] = tasks.filter((task) => task.state === column.id)
|
||||
return acc
|
||||
}, {} as Record<TaskState, Task[]>)
|
||||
}, [tasks])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const taskId = active.id as string
|
||||
const newState = over.id as TaskState
|
||||
|
||||
updateTask.mutate({
|
||||
taskId,
|
||||
updates: { state: newState },
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center p-8">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} 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>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## KanbanColumn
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanColumn.tsx
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import TaskCard from './TaskCard'
|
||||
import { Task, TaskState } from '@/types/task'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskState
|
||||
title: string
|
||||
color: string
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
export default function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
|
||||
const { setNodeRef } = useDroppable({ id })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 flex-shrink-0">
|
||||
<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>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="flex-1 bg-gray-50 border border-t-0 border-gray-200 rounded-b-lg p-3 min-h-[200px]"
|
||||
>
|
||||
<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">
|
||||
Sin tareas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## TaskCard
|
||||
|
||||
```typescript
|
||||
// components/kanban/TaskCard.tsx
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Clock, User, GitBranch, AlertCircle } from 'lucide-react'
|
||||
import { Task } from '@/types/task'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-blue-100 text-blue-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
export default function TaskCard({ task }: TaskCardProps) {
|
||||
const navigate = useNavigate()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="card cursor-move hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/tasks/${task.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-sm line-clamp-2">{task.title}</h4>
|
||||
<span className={`badge ${PRIORITY_COLORS[task.priority]}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-3">{task.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{task.assignedAgent && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>Agent {task.assignedAgent.podName.slice(0, 8)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.branchName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{task.branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.state === 'needs_input' && (
|
||||
<div className="flex items-center gap-1 text-yellow-600">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>Pregunta pendiente</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.actualDurationMinutes && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{task.actualDurationMinutes}min</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.previewUrl && (
|
||||
<a
|
||||
href={task.previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 text-xs text-primary-600 hover:underline block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Ver Preview →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## WebTerminal
|
||||
|
||||
```typescript
|
||||
// components/terminal/WebTerminal.tsx
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
interface WebTerminalProps {
|
||||
agentId: string
|
||||
podName: string
|
||||
}
|
||||
|
||||
export function WebTerminal({ agentId, podName }: WebTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal>()
|
||||
const fitAddonRef = useRef<FitAddon>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return
|
||||
|
||||
// Create terminal
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(terminalRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect to backend WebSocket for terminal
|
||||
const ws = new WebSocket(`ws://localhost:3000/terminal/${agentId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
term.writeln(`Connected to ${podName}`)
|
||||
term.writeln('')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data)
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
ws.send(data)
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit()
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
term.dispose()
|
||||
ws.close()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [agentId, podName])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#1e1e1e] rounded-lg overflow-hidden">
|
||||
<div ref={terminalRef} className="h-full w-full p-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## TaskForm
|
||||
|
||||
```typescript
|
||||
// components/tasks/TaskForm.tsx
|
||||
import { useState } from 'react'
|
||||
import { useCreateTask } from '@/hooks/useTasks'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface TaskFormProps {
|
||||
projectId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function TaskForm({ projectId, onSuccess }: TaskFormProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium')
|
||||
|
||||
const createTask = useCreateTask()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!title.trim()) {
|
||||
toast.error('El título es requerido')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createTask.mutateAsync({
|
||||
projectId,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
})
|
||||
|
||||
toast.success('Tarea creada')
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setPriority('medium')
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error('Error al crear tarea')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Título"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ej: Implementar autenticación"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe la tarea en detalle..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Prioridad"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as any)}
|
||||
options={[
|
||||
{ value: 'low', label: 'Baja' },
|
||||
{ value: 'medium', label: 'Media' },
|
||||
{ value: 'high', label: 'Alta' },
|
||||
{ value: 'urgent', label: 'Urgente' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={createTask.isPending} className="w-full">
|
||||
Crear Tarea
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## AgentCard
|
||||
|
||||
```typescript
|
||||
// components/agents/AgentCard.tsx
|
||||
import { Agent } from '@/types/agent'
|
||||
import { Activity, Clock, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent
|
||||
onOpenTerminal?: (agentId: string) => void
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
idle: { color: 'green', icon: CheckCircle, label: 'Inactivo' },
|
||||
busy: { color: 'blue', icon: Activity, label: 'Trabajando' },
|
||||
error: { color: 'red', icon: AlertCircle, label: 'Error' },
|
||||
offline: { color: 'gray', icon: AlertCircle, label: 'Offline' },
|
||||
initializing: { color: 'yellow', icon: Clock, label: 'Inicializando' },
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, onOpenTerminal }: AgentCardProps) {
|
||||
const config = STATUS_CONFIG[agent.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{agent.podName}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">ID: {agent.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
|
||||
<span className={`badge bg-${config.color}-100 text-${config.color}-800`}>
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Tareas completadas:</span>
|
||||
<span className="font-medium">{agent.tasksCompleted}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Tiempo total:</span>
|
||||
<span className="font-medium">{agent.totalRuntimeMinutes}min</span>
|
||||
</div>
|
||||
|
||||
{agent.lastHeartbeat && (
|
||||
<div className="flex justify-between">
|
||||
<span>Último heartbeat:</span>
|
||||
<span className="font-medium">
|
||||
{formatDistanceToNow(new Date(agent.lastHeartbeat), {
|
||||
addSuffix: true,
|
||||
locale: es,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent.currentTask && (
|
||||
<div className="mt-3 p-2 bg-blue-50 rounded text-sm">
|
||||
<p className="text-blue-900 font-medium">Tarea actual:</p>
|
||||
<p className="text-blue-700 text-xs mt-1">{agent.currentTask.title}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.capabilities && agent.capabilities.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{agent.capabilities.map((cap) => (
|
||||
<span key={cap} className="badge bg-gray-100 text-gray-700 text-xs">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onOpenTerminal && (
|
||||
<button
|
||||
onClick={() => onOpenTerminal(agent.id)}
|
||||
className="mt-3 w-full btn-secondary text-sm"
|
||||
>
|
||||
Abrir Terminal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user