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>
|
||||
)
|
||||
}
|
||||
```
|
||||
422
docs/03-frontend/consolas-web.md
Normal file
422
docs/03-frontend/consolas-web.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Consolas Web con xterm.js
|
||||
|
||||
## Implementación del Terminal Web
|
||||
|
||||
### WebTerminal Component
|
||||
|
||||
```typescript
|
||||
// components/terminal/WebTerminal.tsx
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links'
|
||||
import { SearchAddon } from 'xterm-addon-search'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
interface WebTerminalProps {
|
||||
agentId: string
|
||||
podName: string
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export function WebTerminal({ agentId, podName, namespace = 'agents' }: WebTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal>()
|
||||
const fitAddonRef = useRef<FitAddon>()
|
||||
const wsRef = useRef<WebSocket>()
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return
|
||||
|
||||
// Create terminal instance
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
selection: '#264f78',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
})
|
||||
|
||||
// Addons
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.loadAddon(searchAddon)
|
||||
|
||||
// Open terminal
|
||||
term.open(terminalRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
// Store refs
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect to backend WebSocket
|
||||
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:3000'}/terminal/${agentId}`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true)
|
||||
setError(null)
|
||||
term.writeln(`\x1b[32m✓\x1b[0m Connected to ${podName}`)
|
||||
term.writeln('')
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
setError('Connection error')
|
||||
term.writeln(`\x1b[31m✗\x1b[0m Connection error`)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false)
|
||||
term.writeln('')
|
||||
term.writeln(`\x1b[33m⚠\x1b[0m Disconnected from ${podName}`)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data)
|
||||
}
|
||||
|
||||
// Send input to backend
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle terminal resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit()
|
||||
// Send resize info to backend
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
term.dispose()
|
||||
ws.close()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [agentId, podName, namespace])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-800 text-white px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="font-mono text-sm">{podName}</span>
|
||||
<span className="text-gray-400 text-xs">({namespace})</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<span className="text-red-400 text-xs">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 bg-[#1e1e1e] overflow-hidden">
|
||||
<div ref={terminalRef} className="h-full w-full p-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal Tabs Manager
|
||||
|
||||
```typescript
|
||||
// components/terminal/TerminalTabs.tsx
|
||||
import { X } from 'lucide-react'
|
||||
import { useTerminalStore } from '@/store/terminalStore'
|
||||
import { WebTerminal } from './WebTerminal'
|
||||
|
||||
export function TerminalTabs() {
|
||||
const { tabs, activeTabId, setActiveTab, closeTerminal } = useTerminalStore()
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
<p>No hay terminales abiertas</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center bg-gray-800 border-b border-gray-700 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 cursor-pointer
|
||||
${tab.isActive ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}
|
||||
border-r border-gray-700
|
||||
`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="font-mono text-sm truncate max-w-[150px]">
|
||||
{tab.podName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTerminal(tab.id)
|
||||
}}
|
||||
className="hover:text-red-400"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active terminal */}
|
||||
<div className="flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`h-full ${tab.isActive ? 'block' : 'hidden'}`}
|
||||
>
|
||||
<WebTerminal
|
||||
agentId={tab.agentId}
|
||||
podName={tab.podName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal Page/View
|
||||
|
||||
```typescript
|
||||
// pages/TerminalsView.tsx
|
||||
import { TerminalTabs } from '@/components/terminal/TerminalTabs'
|
||||
import { useAgents } from '@/hooks/useAgents'
|
||||
import { useTerminalStore } from '@/store/terminalStore'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
export default function TerminalsView() {
|
||||
const { data: agents = [] } = useAgents()
|
||||
const { openTerminal } = useTerminalStore()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar with agents */}
|
||||
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Agentes Disponibles</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => openTerminal(agent.id, agent.podName)}
|
||||
className="w-full text-left p-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm truncate">{agent.podName}</span>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
agent.status === 'idle' ? 'bg-green-400' :
|
||||
agent.status === 'busy' ? 'bg-blue-400' :
|
||||
'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{agent.status}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminals */}
|
||||
<div className="flex-1">
|
||||
<TerminalTabs />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Backend WebSocket Handler
|
||||
|
||||
```typescript
|
||||
// backend: api/websocket/terminal.ts
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import { K8sClient } from '../../services/kubernetes/client'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
const k8sClient = new K8sClient()
|
||||
|
||||
export function setupTerminalWebSocket(io: SocketIOServer) {
|
||||
io.of('/terminal').on('connection', async (socket: Socket) => {
|
||||
const agentId = socket.handshake.query.agentId as string
|
||||
|
||||
if (!agentId) {
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Terminal connection: agent ${agentId}`)
|
||||
|
||||
try {
|
||||
// Get agent pod info
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
socket.emit('error', { message: 'Agent not found' })
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to K8s pod exec
|
||||
const stream = await k8sClient.execInPod({
|
||||
namespace: agent.k8sNamespace,
|
||||
podName: agent.podName,
|
||||
command: ['/bin/bash'],
|
||||
})
|
||||
|
||||
// Forward data from K8s to client
|
||||
stream.stdout.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString())
|
||||
})
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString())
|
||||
})
|
||||
|
||||
// Forward data from client to K8s
|
||||
socket.on('data', (data: string) => {
|
||||
stream.stdin.write(data)
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
socket.on('resize', ({ cols, rows }: { cols: number; rows: number }) => {
|
||||
stream.resize({ cols, rows })
|
||||
})
|
||||
|
||||
// Cleanup on disconnect
|
||||
socket.on('disconnect', () => {
|
||||
logger.info(`Terminal disconnected: agent ${agentId}`)
|
||||
stream.stdin.end()
|
||||
stream.destroy()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Terminal connection error:', error)
|
||||
socket.emit('error', { message: 'Failed to connect to pod' })
|
||||
socket.disconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Features Adicionales
|
||||
|
||||
### Copy/Paste
|
||||
|
||||
```typescript
|
||||
// En WebTerminal component
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
// Ctrl+C / Cmd+C
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(text)
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
```
|
||||
|
||||
### Clear Terminal
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={() => xtermRef.current?.clear()}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
```
|
||||
|
||||
### Download Log
|
||||
|
||||
```typescript
|
||||
const downloadLog = () => {
|
||||
if (!xtermRef.current) return
|
||||
|
||||
const buffer = xtermRef.current.buffer.active
|
||||
let content = ''
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i)
|
||||
if (line) {
|
||||
content += line.translateToString(true) + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${podName}-${Date.now()}.log`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
```
|
||||
504
docs/03-frontend/estado.md
Normal file
504
docs/03-frontend/estado.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Gestión de Estado
|
||||
|
||||
## React Query para Server State
|
||||
|
||||
```typescript
|
||||
// hooks/useTasks.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Task, CreateTaskInput, UpdateTaskInput } from '@/types/task'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export function useTasks(filters?: { projectId?: string; state?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ tasks: Task[] }>('/tasks', { params: filters })
|
||||
return data.tasks
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTask(taskId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', taskId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ task: Task }>(`/tasks/${taskId}`)
|
||||
return data.task
|
||||
},
|
||||
enabled: !!taskId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateTaskInput) => {
|
||||
const { data } = await api.post('/tasks', input)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ taskId, updates }: { taskId: string; updates: UpdateTaskInput }) => {
|
||||
const { data } = await api.patch(`/tasks/${taskId}`, updates)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRespondToQuestion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
questionId,
|
||||
response,
|
||||
}: {
|
||||
taskId: string
|
||||
questionId: string
|
||||
response: string
|
||||
}) => {
|
||||
const { data } = await api.post(`/tasks/${taskId}/respond`, {
|
||||
questionId,
|
||||
response,
|
||||
})
|
||||
return data
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success('Respuesta enviada')
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const { data } = await api.post(`/tasks/${taskId}/approve`)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Tarea aprobada')
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// hooks/useProjects.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Project, CreateProjectInput } from '@/types/project'
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ projects: Project[] }>('/projects')
|
||||
return data.projects
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useProject(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', projectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ project: Project }>(`/projects/${projectId}`)
|
||||
return data.project
|
||||
},
|
||||
enabled: !!projectId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateProjectInput) => {
|
||||
const { data } = await api.post('/projects', input)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// hooks/useAgents.ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Agent } from '@/types/agent'
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ agents: Agent[] }>('/agents')
|
||||
return data.agents
|
||||
},
|
||||
refetchInterval: 5000, // Refetch every 5s
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgent(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', agentId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ agent: Agent }>(`/agents/${agentId}`)
|
||||
return data.agent
|
||||
},
|
||||
enabled: !!agentId,
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentLogs(agentId: string, limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', agentId, 'logs', limit],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/agents/${agentId}/logs`, {
|
||||
params: { limit },
|
||||
})
|
||||
return data.logs
|
||||
},
|
||||
enabled: !!agentId,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Zustand para Client State
|
||||
|
||||
```typescript
|
||||
// store/authStore.ts
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
login: (token: string, user: User) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (token, user) => {
|
||||
set({ token, user, isAuthenticated: true })
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, token: null, isAuthenticated: false })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// store/uiStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean
|
||||
activeModal: string | null
|
||||
toggleSidebar: () => void
|
||||
openModal: (modalId: string) => void
|
||||
closeModal: () => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
activeModal: null,
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
openModal: (modalId) => set({ activeModal: modalId }),
|
||||
|
||||
closeModal: () => set({ activeModal: null }),
|
||||
}))
|
||||
```
|
||||
|
||||
```typescript
|
||||
// store/terminalStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface TerminalTab {
|
||||
id: string
|
||||
agentId: string
|
||||
podName: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface TerminalState {
|
||||
tabs: TerminalTab[]
|
||||
activeTabId: string | null
|
||||
openTerminal: (agentId: string, podName: string) => void
|
||||
closeTerminal: (tabId: string) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
|
||||
openTerminal: (agentId, podName) =>
|
||||
set((state) => {
|
||||
const existingTab = state.tabs.find((t) => t.agentId === agentId)
|
||||
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabs: state.tabs.map((t) => ({
|
||||
...t,
|
||||
isActive: t.id === existingTab.id,
|
||||
})),
|
||||
activeTabId: existingTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: TerminalTab = {
|
||||
id: `term-${Date.now()}`,
|
||||
agentId,
|
||||
podName,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: [
|
||||
...state.tabs.map((t) => ({ ...t, isActive: false })),
|
||||
newTab,
|
||||
],
|
||||
activeTabId: newTab.id,
|
||||
}
|
||||
}),
|
||||
|
||||
closeTerminal: (tabId) =>
|
||||
set((state) => {
|
||||
const newTabs = state.tabs.filter((t) => t.id !== tabId)
|
||||
const newActiveTab = newTabs.length > 0 ? newTabs[0].id : null
|
||||
|
||||
return {
|
||||
tabs: newTabs.map((t, i) => ({
|
||||
...t,
|
||||
isActive: i === 0,
|
||||
})),
|
||||
activeTabId: newActiveTab,
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveTab: (tabId) =>
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((t) => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
})),
|
||||
activeTabId: tabId,
|
||||
})),
|
||||
}))
|
||||
```
|
||||
|
||||
## WebSocket Hook
|
||||
|
||||
```typescript
|
||||
// hooks/useWebSocket.ts
|
||||
import { useEffect } from 'use'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export function useWebSocket() {
|
||||
const queryClient = useQueryClient()
|
||||
const token = useAuthStore((state) => state.token)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
// Initialize socket
|
||||
socket = io(import.meta.env.VITE_WS_URL || 'http://localhost:3000', {
|
||||
auth: { token },
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('WebSocket disconnected')
|
||||
})
|
||||
|
||||
// Task events
|
||||
socket.on('task:created', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
toast.success(`Nueva tarea: ${data.title}`)
|
||||
})
|
||||
|
||||
socket.on('task:status_changed', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
||||
|
||||
if (data.newState === 'ready_to_test') {
|
||||
toast.success('Tarea lista para probar!', {
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('task:needs_input', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
||||
toast((t) => (
|
||||
<div>
|
||||
<p className="font-medium">El agente necesita información</p>
|
||||
<p className="text-sm text-gray-600 mt-1">{data.question}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Navigate to task
|
||||
window.location.href = `/tasks/${data.taskId}`
|
||||
toast.dismiss(t.id)
|
||||
}}
|
||||
className="mt-2 text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Ver tarea →
|
||||
</button>
|
||||
</div>
|
||||
), {
|
||||
duration: 10000,
|
||||
icon: '❓',
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('task:pr_created', (data) => {
|
||||
toast.success('Pull Request creado!', {
|
||||
action: {
|
||||
label: 'Ver PR',
|
||||
onClick: () => window.open(data.prUrl, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('task:ready_to_test', (data) => {
|
||||
toast.success('Preview deploy completado!', {
|
||||
action: {
|
||||
label: 'Ver Preview',
|
||||
onClick: () => window.open(data.previewUrl, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Agent events
|
||||
socket.on('agent:status', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
})
|
||||
|
||||
// Deploy events
|
||||
socket.on('deploy:started', (data) => {
|
||||
toast.loading(`Desplegando a ${data.environment}...`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('deploy:completed', (data) => {
|
||||
toast.success(`Deploy completado: ${data.environment}`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
action: {
|
||||
label: 'Abrir',
|
||||
onClick: () => window.open(data.url, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('deploy:failed', (data) => {
|
||||
toast.error(`Deploy falló: ${data.environment}`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
}, [token, queryClient])
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
// Export for manual usage
|
||||
export function getSocket() {
|
||||
return socket
|
||||
}
|
||||
```
|
||||
|
||||
## API Client
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
```
|
||||
420
docs/03-frontend/estructura.md
Normal file
420
docs/03-frontend/estructura.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Estructura del Frontend
|
||||
|
||||
## Árbol de Directorios
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/
|
||||
│ └── favicon.ico
|
||||
│
|
||||
├── src/
|
||||
│ ├── main.tsx # Entry point
|
||||
│ ├── App.tsx # App root
|
||||
│ │
|
||||
│ ├── pages/
|
||||
│ │ ├── Dashboard.tsx # Main dashboard
|
||||
│ │ ├── ProjectView.tsx # Single project view
|
||||
│ │ ├── TaskDetail.tsx # Task details modal
|
||||
│ │ └── AgentsView.tsx # Agents monitoring
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── kanban/
|
||||
│ │ │ ├── KanbanBoard.tsx
|
||||
│ │ │ ├── KanbanColumn.tsx
|
||||
│ │ │ ├── TaskCard.tsx
|
||||
│ │ │ └── TaskCardActions.tsx
|
||||
│ │ │
|
||||
│ │ ├── terminal/
|
||||
│ │ │ ├── WebTerminal.tsx
|
||||
│ │ │ └── TerminalTab.tsx
|
||||
│ │ │
|
||||
│ │ ├── projects/
|
||||
│ │ │ ├── ProjectCard.tsx
|
||||
│ │ │ ├── ProjectForm.tsx
|
||||
│ │ │ └── ProjectSettings.tsx
|
||||
│ │ │
|
||||
│ │ ├── tasks/
|
||||
│ │ │ ├── TaskForm.tsx
|
||||
│ │ │ ├── TaskQuestion.tsx
|
||||
│ │ │ └── TaskTimeline.tsx
|
||||
│ │ │
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── AgentCard.tsx
|
||||
│ │ │ ├── AgentStatus.tsx
|
||||
│ │ │ └── AgentLogs.tsx
|
||||
│ │ │
|
||||
│ │ ├── deployments/
|
||||
│ │ │ ├── DeploymentList.tsx
|
||||
│ │ │ ├── DeploymentCard.tsx
|
||||
│ │ │ └── DeployButton.tsx
|
||||
│ │ │
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── Button.tsx
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── Card.tsx
|
||||
│ │ │ ├── Badge.tsx
|
||||
│ │ │ ├── Input.tsx
|
||||
│ │ │ ├── Select.tsx
|
||||
│ │ │ └── Spinner.tsx
|
||||
│ │ │
|
||||
│ │ └── layout/
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ └── Navigation.tsx
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useProjects.ts
|
||||
│ │ ├── useTasks.ts
|
||||
│ │ ├── useAgents.ts
|
||||
│ │ ├── useWebSocket.ts
|
||||
│ │ ├── useTaskActions.ts
|
||||
│ │ └── useDeployments.ts
|
||||
│ │
|
||||
│ ├── store/
|
||||
│ │ ├── authStore.ts
|
||||
│ │ ├── uiStore.ts
|
||||
│ │ └── terminalStore.ts
|
||||
│ │
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # Axios instance
|
||||
│ │ ├── projects.ts
|
||||
│ │ ├── tasks.ts
|
||||
│ │ ├── agents.ts
|
||||
│ │ ├── deployments.ts
|
||||
│ │ └── websocket.ts
|
||||
│ │
|
||||
│ ├── types/
|
||||
│ │ ├── project.ts
|
||||
│ │ ├── task.ts
|
||||
│ │ ├── agent.ts
|
||||
│ │ ├── deployment.ts
|
||||
│ │ └── common.ts
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── format.ts
|
||||
│ │ ├── validation.ts
|
||||
│ │ └── constants.ts
|
||||
│ │
|
||||
│ └── styles/
|
||||
│ └── index.css # Tailwind imports
|
||||
│
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Setup Inicial
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "aiworker-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx",
|
||||
"format": "prettier --write src/**/*.{ts,tsx}"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"@tanstack/react-query": "^6.3.0",
|
||||
"zustand": "^5.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"axios": "^1.7.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^9.1.0",
|
||||
"xterm": "^5.5.0",
|
||||
"xterm-addon-fit": "^0.10.0",
|
||||
"xterm-addon-web-links": "^0.11.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"recharts": "^2.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.4.49",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### tailwind.config.js
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
},
|
||||
warning: {
|
||||
50: '#fefce8',
|
||||
500: '#eab308',
|
||||
600: '#ca8a04',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### main.tsx
|
||||
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
```
|
||||
|
||||
### App.tsx
|
||||
|
||||
```typescript
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/layout/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ProjectView from './pages/ProjectView'
|
||||
import AgentsView from './pages/AgentsView'
|
||||
import { WebSocketProvider } from './api/websocket'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WebSocketProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectView />} />
|
||||
<Route path="/agents" element={<AgentsView />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</WebSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
### styles/index.css
|
||||
|
||||
```css
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-200 text-gray-700 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
bun run dev
|
||||
|
||||
# Build
|
||||
bun run build
|
||||
|
||||
# Preview build
|
||||
bun run preview
|
||||
|
||||
# Lint
|
||||
bun run lint
|
||||
|
||||
# Format
|
||||
bun run format
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
```bash
|
||||
# .env
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_WS_URL=ws://localhost:3000
|
||||
```
|
||||
|
||||
## Estructura de Componentes
|
||||
|
||||
Los componentes siguen esta estructura:
|
||||
|
||||
```typescript
|
||||
// Imports
|
||||
import { useState } from 'react'
|
||||
import { SomeIcon } from 'lucide-react'
|
||||
|
||||
// Types
|
||||
interface ComponentProps {
|
||||
prop1: string
|
||||
prop2?: number
|
||||
}
|
||||
|
||||
// Component
|
||||
export function Component({ prop1, prop2 = 0 }: ComponentProps) {
|
||||
// State
|
||||
const [state, setState] = useState<string>('')
|
||||
|
||||
// Handlers
|
||||
const handleAction = () => {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Render
|
||||
return (
|
||||
<div className="component">
|
||||
{/* JSX */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
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