Files
aiworker/docs/03-frontend/componentes.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

14 KiB

Componentes Principales

KanbanBoard

// 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

// 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

// 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

// 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

// 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

// 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>
  )
}