Ir al contenido

Planificación

Las cinco vistas para planificar y seguir trabajo: el calendario, el tablero, el diagrama de barras, el historial y el asistente por pasos.

Los cuatro primeros comparten dos convenciones:

  • Son de solo lectura hasta que les das el callback de cambio. Sin onEventChange, onCardMove ni onTaskChange no se puede arrastrar nada, que es lo que quieres en una vista de consulta.
  • El «hoy» es fijable. today en Calendar y Gantt, now en Timeline: así una captura o una prueba no dependen del reloj de quien la ejecuta.

Calendario de semana y de mes, con eventos arrastrables y una cola de tarjetas sin programar.

import { Calendar, type CalendarEvent } from '@openfactu/ui';
const EVENTOS: CalendarEvent[] = [
{ id: 'hormigon', title: 'Llevar hormigón', start: '2026-07-22T07:00:00', end: '2026-07-22T08:00:00', status: 'en-curso', meta: 'A. Pérez' },
{ id: 'revision', title: 'Revisión de facturas', start: '2026-07-21T09:00:00', end: '2026-07-21T11:00:00', status: 'hecha' },
{ id: 'visita', title: 'Visita de obra', start: '2026-07-23T16:30:00', end: '2026-07-23T18:00:00', status: 'pendiente', meta: 'Con el cliente' },
{ id: 'inventario', title: 'Cierre por inventario', start: '2026-07-25T00:00:00', end: '2026-07-25T23:59:00', allDay: true, status: 'bloqueada' },
];
<Calendar events={EVENTOS} aria-label="Calendario de tareas" />

El color del bloque sale de statuspendiente, en-curso, hecha, bloqueada—, y color lo pisa si necesitas uno propio. Los eventos con allDay van a la banda superior, sin hora.

<Calendar
events={eventos}
onEventChange={(evento, { start, end }) => reprogramar(evento.id, start, end)}
onEventClick={abrirFicha}
onSlotClick={(inicio) => crearRapido(inicio)}
unscheduled={sinFecha}
onSchedule={(evento, inicio) => programar(evento.id, inicio)}
unscheduledLabel="Pendientes de asignar"
/>

unscheduled pinta una cola lateral de tarjetas sin fecha: al arrastrar una al calendario se llama a onSchedule. Es el flujo de asignar partes de trabajo a un día.

Un evento con locked ni se mueve ni se redimensiona, aunque el calendario sea editable.

<Calendar
events={eventos}
view={vista}
onViewChange={setVista}
date={dia}
onDateChange={setDia}
hourStart={7}
hourEnd={20}
hourHeight={44}
/>
Prop Tipo Por defecto Descripción
events CalendarEvent[] Eventos
view 'week' | 'month' 'week' Vista
onViewChange (v: CalendarView) => void Cambio de vista
date Date | string hoy Día de referencia: define la semana o el mes visible
onDateChange (d: Date) => void Cambio de fecha visible
toolbar boolean true Barra con «‹ Hoy ›» y el selector de vista
hourStart / hourEnd number 6 / 22 Primera y última hora de la rejilla
hourHeight number 36 Alto en píxeles de cada hora
today Date | string el reloj Qué día se considera hoy
onEventChange (e: CalendarEvent, c: CalendarChange) => void Mover o cambiar duración. Sin esto es solo lectura
onEventClick (e: CalendarEvent) => void Click en un evento
onSlotClick (start: Date) => void Click en un hueco vacío: crear rápido
unscheduled CalendarEvent[] Cola de tarjetas sin fecha
onSchedule (e: CalendarEvent, start: Date) => void Se arrastra una tarjeta al calendario
unscheduledLabel ReactNode 'Sin programar' Título de la cola
maxPorDia number 3 Eventos por casilla del mes antes del «+N más»
emptyMessage ReactNode 'No hay nada en estas fechas.' Estado vacío
aria-label string Nombre accesible
className string Clases
Prop Tipo Descripción
id string Identificador
title string Título del bloque
start / end Date | string Inicio y fin
allDay boolean Va a la banda superior, sin hora
status 'pendiente' | 'en-curso' | 'hecha' | 'bloqueada' Decide el color
color string Color propio; manda sobre el del estado
textColor string Texto sobre color
locked boolean Ni se mueve ni se redimensiona
meta ReactNode Segunda línea dentro del bloque

Tablero de columnas con tarjetas arrastrables y límite de trabajo en curso.

import { Kanban, Badge, Avatar, type KanbanColumn } from '@openfactu/ui';
const COLUMNAS: KanbanColumn[] = [
{
id: 'por-hacer',
label: 'Por hacer',
cards: [
{
id: 't1',
title: 'Revisar facturas de agosto',
description: 'Cuadrar con el extracto bancario antes del cierre.',
badges: <Badge variant="warning">Vence hoy</Badge>,
meta: <Avatar name="Ana Pérez" size="xs" />,
tone: 'warning',
},
{ id: 't2', title: 'Pedir presupuesto de grúa', badges: <Badge variant="neutral">Compras</Badge> },
],
},
{
id: 'en-curso',
label: 'En curso',
tone: 'accent',
wipLimit: 2,
cards: [
{
id: 't3',
title: 'Alta de proveedor Talleres del Norte',
description: 'Falta el certificado de estar al corriente.',
tone: 'accent',
meta: <Avatar name="Bruno Gil" size="xs" />,
},
],
},
{ id: 'bloqueadas', label: 'Bloqueadas', tone: 'danger', cards: [] },
];
<Kanban columns={COLUMNAS} aria-label="Tablero de tareas" />

wipLimit es un aviso, no una prohibición: al pasarse, el contador de la columna lo señala, pero no impide soltar nada.

<Kanban
columns={columnas}
onCardMove={(tarjeta, destino, origen) => {
setColumnas((prev) => {
const copia = estructuraProfunda(prev);
copia[indice(origen.columnId)].cards.splice(origen.index, 1);
copia[indice(destino.columnId)].cards.splice(destino.index, 0, tarjeta);
return copia;
});
}}
onCardClick={abrirTarea}
/>

Una tarjeta con locked no se arrastra; una columna con locked no acepta que le suelten nada.

renderCard sustituye el pintado conservando el arrastre:

<Kanban
columns={columnas}
onCardMove={mover}
renderCard={(tarjeta, columna) => <MiTarjeta tarea={tarjeta} columna={columna.id} />}
/>
Prop Tipo Por defecto Descripción
columns KanbanColumn[] Columnas con sus tarjetas
onCardMove (card, to: KanbanMove, from: KanbanMove) => void Arrastrar y soltar. Sin esto es solo lectura
onCardClick (card: KanbanCard) => void Click en una tarjeta
renderCard (card, column) => ReactNode Sustituye el pintado de la tarjeta
columnWidth number 260 Ancho de cada columna en píxeles
maxHeight number | string Alto máximo de la lista; a partir de ahí, scroll propio
aria-label string Nombre accesible
className string Clases
Prop Tipo Descripción
id string Identificador
label ReactNode Título de la columna
cards KanbanCard[] Tarjetas
wipLimit number Máximo recomendado; avisa al pasarse
tone KanbanTone Color del encabezado
locked boolean No acepta que le suelten nada
emptyMessage ReactNode Qué poner con la columna vacía
Prop Tipo Descripción
id string Identificador
title ReactNode Título
description ReactNode Detalle bajo el título
badges ReactNode Chips: etiquetas, prioridad
meta ReactNode Bloque a la derecha: avatar, importe, fecha
tone KanbanTone Color de la tarjeta
locked boolean No se puede arrastrar

KanbanMove es { columnId, index }, con index empezando en 0.

Diagrama de barras con dependencias, avance, agrupación y cuatro escalas.

import { Gantt, type GanttTask } from '@openfactu/ui';
const OBRA: GanttTask[] = [
{
id: 'proyecto',
name: 'Redacción del proyecto',
start: '2026-07-06',
end: '2026-07-17',
status: 'hecha',
progress: 1,
group: 'Preparación',
},
{
id: 'licencia',
name: 'Licencia de obra',
start: '2026-07-20',
end: '2026-08-07',
status: 'en-curso',
progress: 0.4,
dependencies: ['proyecto'],
group: 'Preparación',
},
{
id: 'cimentacion',
name: 'Cimentación',
start: '2026-08-10',
end: '2026-08-28',
status: 'pendiente',
dependencies: ['licencia'],
group: 'Ejecución',
},
];
<Gantt tasks={OBRA} scale="week" aria-label="Planificación de la obra" />

group mete las tareas bajo una fila de encabezado —proyecto, fase, persona—; dependencies dibuja la flecha entre barras; progress (de 0 a 1) rellena el avance dentro de la barra.

<Gantt
tasks={tareas}
scale={escala} {/* 'day' | 'week' | 'month' | 'quarter' */}
rangeStart="2026-07-01"
rangeEnd="2026-12-31"
onTaskChange={(tarea, { start, end }) => replanificar(tarea.id, start, end)}
onTaskClick={abrirTarea}
labelWidth={260}
rowHeight={40}
/>

Se combina bien con un SegmentedControl para cambiar de escala.

Prop Tipo Por defecto Descripción
tasks GanttTask[] Tareas
scale 'day' | 'week' | 'month' | 'quarter' 'week' Escala del eje
rangeStart / rangeEnd Date | string los de las tareas, con margen Primer y último día visibles
onTaskChange (task, change: GanttChange) => void Mover o cambiar duración. Sin esto es solo lectura
onTaskClick (task: GanttTask) => void Click en una tarea
labelWidth number 220 Ancho de la columna de nombres
rowHeight number 34 Alto de cada fila
showToday boolean true Marca vertical del día de hoy
today Date | string el reloj Qué se considera hoy
emptyMessage string 'No hay tareas que mostrar.' Estado vacío
aria-label string Nombre accesible
className string Clases
Prop Tipo Descripción
id string Identificador
name string Nombre, en la columna izquierda
start / end Date | string Inicio y fin, inclusive. Iguales = hito
progress number 0..1; relleno de avance dentro de la barra
status 'pendiente' | 'en-curso' | 'hecha' | 'bloqueada' Decide el color
color / textColor string Color propio y su color de texto
dependencies string[] Ids de los que depende; dibuja la flecha
group string Fila de encabezado que la agrupa
locked boolean Ni se mueve ni se redimensiona
meta ReactNode Texto a la derecha de la barra

El historial de un documento: quién hizo qué y cuándo.

import { Timeline, type TimelineEvent } from '@openfactu/ui';
import { FileText, Pencil, Send } from 'lucide-react';
const HISTORIAL: TimelineEvent[] = [
{
id: '1',
title: 'Factura creada',
description: 'FAC/2026/0042 por 1.240,00 € a Construcciones Acme S.L.',
author: 'Ana Pérez',
date: '2026-07-20T09:12:00',
icon: <FileText className="h-3.5 w-3.5" />,
},
{
id: '2',
title: 'Línea modificada',
description: 'Tornillos de 10 cm: 100 → 120 unidades.',
author: 'Bruno Gil',
date: '2026-07-20T11:40:00',
icon: <Pencil className="h-3.5 w-3.5" />,
},
{
id: '3',
title: 'Rechazada por la AEAT',
description: 'Código 3002: el NIF del destinatario no consta en el censo.',
author: 'Sistema',
date: '2026-07-21T08:06:00',
tone: 'danger',
icon: <Send className="h-3.5 w-3.5" />,
},
];
<Timeline events={HISTORIAL} aria-label="Historial del documento" />

Por defecto agrupa por día y enseña fechas relativas («hace 2 h»). content es un bloque libre bajo el texto, para meter las diferencias de un cambio, un adjunto o un importe.

Una línea por evento, para paneles laterales:

<Timeline events={historial} variant="compacto" groupByDay={false} />
Prop Tipo Por defecto Descripción
events TimelineEvent[] Eventos
variant 'detallado' | 'compacto' 'detallado' compacto es una línea por evento
groupByDay boolean true en detallado Encabezado por día
relative boolean true Fechas relativas en lugar de la hora
now Date | string el reloj Qué se considera ahora
emptyMessage ReactNode 'Todavía no hay actividad.' Estado vacío
aria-label string Nombre accesible
className string Clases
Prop Tipo Descripción
id string Identificador
title ReactNode Qué pasó
description ReactNode Detalle bajo el título
author ReactNode Quién lo hizo
date Date | string Cuándo
icon ReactNode Icono del punto
tone TimelineTone Color del punto
content ReactNode Bloque libre bajo el texto
onClick () => void Click en el evento

El asistente por pasos.

import { Stepper, type Step } from '@openfactu/ui';
const PASOS: Step[] = [
{ id: 'empresa', label: 'Datos de empresa', description: 'CIF, domicilio y actividad' },
{ id: 'fiscal', label: 'Configuración fiscal', description: 'Régimen y series' },
{ id: 'usuarios', label: 'Usuarios', description: 'Invita a tu equipo' },
{ id: 'listo', label: 'Listo' },
];
<Stepper steps={PASOS} current={paso} onStepClick={(_, i) => setPaso(i)} />

current es el índice del paso en curso; los anteriores se dan por hechos sin que tengas que marcarlos.

status en un paso concreto lo pisa todo, así que puedes marcar un error sin mover el paso actual:

<Stepper
current={2}
steps={[
{ id: 'empresa', label: 'Datos de empresa' },
{ id: 'fiscal', label: 'Configuración fiscal', status: 'error' },
{ id: 'usuarios', label: 'Usuarios' },
]}
/>
<Stepper steps={PASOS} current={paso} orientation="vertical" size="sm" />
Prop Tipo Por defecto Descripción
steps Step[] Pasos
current number 0 Índice del paso en curso
orientation 'horizontal' | 'vertical' 'horizontal' Disposición
size 'sm' | 'md' 'md' Tamaño
onStepClick (step: Step, index: number) => void Volver a un paso
allowForward boolean false Deja pulsar también los pendientes
aria-label string Nombre accesible
className string Clases
Prop Tipo Descripción
id string Identificador
label ReactNode Nombre del paso
description ReactNode Detalle bajo el nombre
status 'hecha' | 'actual' | 'pendiente' | 'error' Se deduce de current si no se indica
icon ReactNode Icono en lugar del número
disabled boolean No se puede ir a este paso