InteracciónSe aplica aSuperScheduler Pro
Arrastrar, redimensionar y reglas de negocio
Dirige cada fotograma del arrastre en onEventMoving y onEventResizing: pon args.allowed = false y args.message para rechazar una posición con una explicación. Impide los solapes con allowEventOverlap={false} (o fotograma a fotograma con args.allowOverlap), bloquea tiempo con celdas deshabilitadas, fija eventos sueltos con moveDisabled y resizeDisabled, y toma la decisión final en onEventMove u onEventResize, de forma asíncrona si hace falta con args.async = true y args.loaded().
Arrastrar es donde un tablero de planificación demuestra lo que vale, y donde viven la mayoría de las reglas de negocio: este trabajo necesita un elevador, esa estancia no puede moverse al pasado, el quirófano cierra a mediodía. SuperScheduler Pro consulta a tu código en dos momentos. Mientras el usuario arrastra, en cada cambio de la sombra, puedes aceptar, rechazar o ajustar la posición y decir por qué. Al soltar, una sola vez, puedes cancelar, modificar o confirmar el cambio, también después de consultar a tu servidor.
Esta guía construye esas reglas sobre la planificación de un taller con boxes de servicio y después trata los solapes, el tiempo cerrado, los bloqueos, la confirmación asíncrona, la tarjeta de arrastre y la creación de eventos seleccionando un rango. Todo lo que aparece aquí requiere Pro; Lite es de solo lectura.
Cómo se decide un arrastre
- El usuario agarra un evento. Los eventos bloqueados (
moveDisabled) no inician un arrastre. - En cada movimiento del puntero que cambia la hora o la fila de destino, se ejecuta
onEventMoving(onEventResizingal redimensionar). Tu regla fijaargs.allowed, puede ajustarargs.startyargs.end, y fijaargs.message. - Después, la librería aplica sus propias comprobaciones: el solape con otros eventos cuando
allowEventOverlapesfalsey las celdas deshabilitadas. Una sombra rechazada se dibuja como prohibida y la tarjeta de arrastre muestra el motivo. - Si se suelta sobre una posición rechazada, no pasa nada: el evento vuelve a su sitio y no se ejecuta ningún callback más.
- Si se suelta sobre una posición aceptada,
onEventMove(onEventResize) se ejecuta una vez, antes de que cambie el almacén. Puede cancelar, modificar o aplazar la confirmación. - Se actualiza el almacén, se ejecuta
onEventMoved(onEventResized) y, en la siguiente microtarea,onEventsChange, como se describe en Eventos controlados y callbacks.
La misma secuencia de confirmación se aplica a los movimientos y redimensionados hechos con el teclado en keyboardMode: 'Full'.
Validar mientras se arrastra
onEventMoving recibe la posición candidata y escribe la decisión en sus propios argumentos:
| Escribible | Efecto |
|---|---|
allowed | false dibuja la sombra como prohibida; soltar ahí no hace nada |
message | Texto que muestra la tarjeta de arrastre mientras allowed es false |
start, end | Ajustan la sombra, por ejemplo para conservar las horas originales cuando solo cambia la fila |
allowOverlap | Sustituye a allowEventOverlap solo en este fotograma |
cssClass, html | Clase y contenido de la sombra |
También lee el contexto: args.e (el evento arrastrado, con tus datos en args.e.data), args.resource y args.row (la fila de destino), args.duration, args.conflicts, args.external (arrastrado desde fuera del scheduler) y las teclas modificadoras. onEventResizing funciona igual con start, end, allowed, message y allowOverlap, y añade args.what, el borde que se arrastra ('start' o 'end').
import { useMemo } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1 (lift)' },
{ id: 'bay-2', name: 'Bay 2 (lift)' },
{ id: 'bay-3', name: 'Bay 3' },
{ id: 'waiting', name: 'Waiting list' },
]
const BAYS_WITH_LIFT: ReadonlySet<SuperScheduler.ResourceId> = new Set(['bay-1', 'bay-2'])
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
{ groupBy: 'Day', format: 'dddd d MMMM' },
{ groupBy: 'Hour', format: 'HH:mm' },
]
/** A custom field of the job data (see "Custom fields" in the data model guide). */
function needsLift(data: SuperScheduler.EventData): boolean {
return 'needsLift' in data && data.needsLift === true
}
interface Props {
readonly jobs: SuperScheduler.EventData[]
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
}
export function WorkshopPlanning({ jobs, onEventsChange }: Props) {
const config = useMemo<SchedulerProps>(
() => ({
startDate: '2026-10-12',
days: 5,
scale: 'CellDuration',
cellDuration: 30,
cellWidth: 48,
timeHeaders: TIME_HEADERS,
businessBeginsHour: 8,
businessEndsHour: 18,
showNonBusiness: false,
useEventBoxes: 'Never',
allowEventOverlap: false,
conflictHighlight: true,
// Runs on every shadow change: keep it synchronous and cheap.
onEventMoving: (args) => {
if (args.start.getTime() < SuperScheduler.Date.now().getTime()) {
args.allowed = false
args.message = 'Jobs cannot be moved into the past.'
return
}
if (needsLift(args.e.data) && !BAYS_WITH_LIFT.has(args.resource)) {
args.allowed = false
args.message = 'This job needs a bay with a lift.'
return
}
// The waiting list may hold overlapping jobs; the bays may not (allowEventOverlap above).
args.allowOverlap = args.resource === 'waiting'
},
onEventResizing: (args) => {
const minutes = (args.end.getTime() - args.start.getTime()) / 60_000
if (minutes < 30) {
args.allowed = false
args.message = 'A job takes at least 30 minutes.'
}
},
}),
[],
)
const owned = useMemo(() => jobs.slice(), [jobs])
return (
<SuperSchedulerComponent
{...config}
resources={BAYS}
events={owned}
onEventsChange={onEventsChange}
/>
)
}Deberías ver una planificación de cinco días en celdas de media hora, de 08:00 a 18:00. Arrastra un trabajo que necesita elevador a Bay 3: la sombra pasa a prohibida y la tarjeta dice «This job needs a bay with a lift.». Arrastra cualquier trabajo sobre otro en un box: se rechaza por solape. Suéltalo en la lista de espera: los dos trabajos se apilan allí. Acorta un trabajo por debajo de 30 minutos: el redimensionado se rechaza.
Impedir solapes
allowEventOverlap={false} rechaza cualquier movimiento, redimensionado o selección de rango que se solape con otro evento de la misma fila. Los intervalos son semiabiertos, así que los eventos consecutivos (uno termina a las 11:00 y el siguiente empieza a las 11:00) nunca cuentan como solape.
Dos herramientas afinan la regla según la situación:
args.allowOverlapenonEventMovingyonEventResizingsustituye a la opción en el fotograma actual, como hace la lista de espera de arriba. Se restablece en cada llamada.args.conflictsenumera los eventos existentes con los que choca la sombra en la fila de destino (hasta ocho), como envoltoriosSuperScheduler.Event. Úsalo para distinguir los conflictos leves de los graves:
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
function isTentative(data: SuperScheduler.EventData): boolean {
return 'status' in data && data.status === 'tentative'
}
/** Tentative jobs may be double-booked; confirmed ones may not. */
export const softConflicts: Pick<
SchedulerProps,
'allowEventOverlap' | 'conflictHighlight' | 'onEventMoving'
> = {
allowEventOverlap: false,
// Outlines the events the shadow collides with (data-conflict) while dragging.
conflictHighlight: true,
onEventMoving: (args) => {
// Up to eight colliding events in the target row: feedback, not exhaustive validation.
const hard = args.conflicts.find((event) => !isTentative(event.data))
if (hard === undefined) {
// For this frame only; the instance option stays false.
args.allowOverlap = true
return
}
args.allowed = false
args.message = `Overlaps ${hard.text()}, which is confirmed.`
},
}conflictHighlight resalta el contorno de los eventos en conflicto mientras el usuario arrastra (reciben un atributo data-conflict y un contorno con el color de peligro), así que el usuario ve qué le estorba, no solo que algo le estorba.
Bloquear tiempo con celdas deshabilitadas
Una celda deshabilitada se dibuja rayada y rechaza los movimientos, redimensionados y selecciones de rango que la tocan. Hay dos formas de deshabilitar celdas:
- Una fila entera:
cellsDisabled: trueen el recurso, para un box en reparación o una habitación fuera de servicio. - Cualquier celda: pon
args.cell.properties.disabled = trueenonBeforeCellRender, para pausas de comida, festivos u horarios de apertura por recurso.
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerBeforeCellRenderArgs, SuperScheduler } from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1' },
{ id: 'bay-2', name: 'Bay 2' },
// Closed for refurbishment: every cell of the row is disabled.
{ id: 'bay-3', name: 'Bay 3', cellsDisabled: true },
]
/**
* Module level, so its identity never changes: a new function per render would invalidate the
* per-cell cache. Disabled cells are hatched and reject drops, resizes and range selection.
*/
function closeLunchBreak(args: SchedulerBeforeCellRenderArgs): void {
if (args.cell.start.getHours() === 13) {
args.cell.properties.disabled = true
args.cell.properties.cssClass = 'lunch-break'
}
}
export function ClosedTime({ jobs }: { jobs: SuperScheduler.EventData[] }) {
const owned = useMemo(() => jobs.slice(), [jobs])
return (
<SuperSchedulerComponent
startDate="2026-10-12"
days={5}
scale="CellDuration"
cellDuration={30}
resources={BAYS}
events={owned}
onBeforeCellRender={closeLunchBreak}
/>
)
}Deberías ver Bay 3 rayado a lo largo de toda su fila y todos los boxes rayados de 13:00 a 14:00. Los trabajos no se pueden soltar ni estirar sobre la pausa de comida.
Otras formas de expresar el tiempo cerrado: ocultarlo del eje con showNonBusiness={false} u onIncludeTimeCell (consulta Horas, minutos, días y zoom), o representarlo como eventos que no se pueden mover ni redimensionar (moveDisabled, resizeDisabled), como un bloque de mantenimiento, que además cuentan como solapes cuando allowEventOverlap es false.
Bloquear eventos concretos
| Campo del evento | Efecto |
|---|---|
moveDisabled | El evento no se puede mover |
resizeDisabled | El evento no se puede redimensionar |
moveHDisabled | Puede cambiar de fila, pero no de hora |
moveVDisabled | Puede cambiar de hora, pero no de fila |
clickDisabled, deleteDisabled | Ignora los clics, o no tiene botón de borrar |
Para todo el scheduler, eventMoveHandling="Disabled" y eventResizeHandling="Disabled" desactivan los gestos. Las reglas que dependen de quién mira (un recepcionista puede mover, un huésped no) son permisos: calcula estos campos a partir del rol del usuario antes de pasar los eventos.
Confirmar al soltar, también de forma asíncrona
onEventMove y onEventResize se ejecutan una vez por operación de soltar, antes de que cambie nada. Pueden:
- Cancelar con
args.preventDefault(). - Modificar el resultado asignando
args.newStart,args.newEndoargs.newResource. - Aplazar con
args.async = truey después llamar aargs.loaded()cuando tengas una respuesta. Llamar aargs.preventDefault()antes deloaded()cancela la operación.
import type {
SchedulerEventMoveArgs,
SchedulerEventResizeArgs,
SuperScheduler,
} from 'super-scheduler'
function inProgress(data: SuperScheduler.EventData): boolean {
return 'status' in data && data.status === 'inProgress'
}
/**
* onEventMove: called once on drop, before the store changes. The library never awaits a
* handler, so an asynchronous decision defers the drop with `async` and finishes it with `loaded()`.
*/
export function confirmMove(args: SchedulerEventMoveArgs): void {
// A synchronous veto needs no async: cancel and return. This final check also covers moves
// made with the keyboard.
if (inProgress(args.e.data) && args.newResource !== args.e.resource()) {
args.preventDefault()
args.control.message('A job in progress stays in its bay.')
return
}
args.async = true
const resource = String(args.newResource)
void (async () => {
try {
const question = `Move ${args.e.text()} to ${resource}, ${args.newStart.toString('ddd d MMM HH:mm')}?`
if (!(await confirmWithUser(question))) {
args.preventDefault()
return
}
await saveBooking({
id: String(args.e.id()),
resource,
start: args.newStart.value,
end: args.newEnd.value,
})
} catch {
args.preventDefault()
args.control.message('The move could not be saved.')
} finally {
// Always: completes the drop, or cancels it when preventDefault() was called first.
args.loaded()
}
})()
}
/** The same protocol for resizing; `what` tells which edge moved. */
export function confirmResize(args: SchedulerEventResizeArgs): void {
args.async = true
void saveBooking({
id: String(args.e.id()),
resource: String(args.e.resource()),
start: args.newStart.value,
end: args.newEnd.value,
})
.catch(() => args.preventDefault())
.finally(() => args.loaded())
}Conéctalos como onEventMove={confirmMove} y onEventResize={confirmResize}. Mientras la decisión está pendiente, el evento se queda en su posición original; se mueve cuando loaded() completa la operación, o se queda donde estaba si se canceló.
La tarjeta de arrastre
Mientras arrastras, una tarjeta junto al puntero muestra las fechas de destino, la duración (noches para rangos de días completos y horas y minutos en los demás casos), la fila de destino y por qué se rechaza una posición: tu args.message o el evento con el que se solapa. También aparece un marcador de fecha en la cabecera de tiempo (headerMarker). Ambos están activados por defecto.
Configura la tarjeta con un único objeto estable:
import { SuperScheduler } from 'super-scheduler'
// One stable object at module level: a new object per render would reconfigure the card.
export const DRAG_CARD: SuperScheduler.DragCardOptions = {
// Intraday work: show the time of both edges.
dateFormat: 'ddd d MMM HH:mm',
movingDateFormat: 'ddd d MMM HH:mm',
// Whole-day ranges count nights by default; other ranges show hours and minutes.
duration: 'auto',
row: true,
labels: { overlapping: 'Overlaps', forbidden: 'Not allowed' },
}
// Replace the content when a refusal needs more room. The string is trusted HTML.
export const DRAG_CARD_WITH_REASON: SuperScheduler.DragCardOptions = {
...DRAG_CARD,
html: (info) => {
if (info.refusal === null) return null // null keeps the default content
const reason = SuperScheduler.Util.escapeHtml(info.refusal)
const row = SuperScheduler.Util.escapeHtml(info.rowName ?? '')
return `<strong>${reason}</strong><br>${row}`
},
}Pasa dragCard={DRAG_CARD}, o dragCard={false} para quitarla. Las etiquetas por defecto están traducidas al inglés, español, catalán, euskera, gallego, alemán, francés, italiano y portugués, según el locale del scheduler; labels las sustituye. La opción html recibe las fechas, el nombre de la fila, el primer conflicto, el mensaje de rechazo y la posición del evento antes del arrastre.
Crear eventos seleccionando tiempo
Arrastrar sobre celdas vacías selecciona un rango de tiempo; onTimeRangeSelected lo comunica con start, end (exclusivo), resource y origin. Crear un evento ahí es decisión de tu aplicación:
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type {
SchedulerEventsChangeArgs,
SchedulerTimeRangeSelectedArgs,
SchedulerTimeRangeSelectingArgs,
SuperScheduler,
} from 'super-scheduler'
const BAYS: SuperScheduler.ResourceData[] = [
{ id: 'bay-1', name: 'Bay 1' },
{ id: 'bay-2', name: 'Bay 2' },
]
/** Steers the selection while it is drawn, as onEventMoving does for moves: four hours at most. */
function limitToFourHours(args: SchedulerTimeRangeSelectingArgs): void {
args.allowed = args.end.getTime() - args.start.getTime() <= 4 * 3_600_000
}
export function CreateOnSelect() {
const [jobs, setJobs] = useState<SuperScheduler.EventData[]>([])
const owned = useMemo(() => jobs.slice(), [jobs])
const onEventsChange = useCallback(
(args: SchedulerEventsChangeArgs) => setJobs([...args.events]),
[],
)
const onTimeRangeSelected = useCallback((args: SchedulerTimeRangeSelectedArgs) => {
// The selection shadow stays until cleared.
args.control.clearSelection()
// A plain click on an empty cell also selects it (origin 'click'): create only on a drag.
if (args.origin !== 'drag') return
setJobs((current) => [
...current,
{
id: crypto.randomUUID(),
resource: args.resource,
// Store strings: start and end arrive as SuperScheduler.Date (end exclusive).
start: args.start.value,
end: args.end.value,
text: 'New job',
},
])
}, [])
return (
<SuperSchedulerComponent
startDate="2026-10-12"
days={5}
scale="CellDuration"
cellDuration={30}
resources={BAYS}
events={owned}
allowEventOverlap={false}
onTimeRangeSelecting={limitToFourHours}
onTimeRangeSelected={onTimeRangeSelected}
onEventsChange={onEventsChange}
/>
)
}Deberías ver una sombra de selección que sigue al puntero, que no crece más allá de cuatro horas ni sobre otro trabajo, y que al soltar se convierte en un evento «New job».
Tres comportamientos que conviene conocer:
- Un clic simple también es una selección. Pulsar una celda vacía dispara
onTimeRangeSelectedconorigin: 'click'y una celda. Compruebaorigin === 'drag'si un clic no debe crear nada. - La selección sigue visible después de soltar, hasta la siguiente selección o hasta
args.control.clearSelection(). - Las selecciones siguen las mismas reglas que los movimientos: no pueden cruzar celdas deshabilitadas ni tiempo ocupado cuando
allowEventOverlapesfalse.onTimeRangeSelectinglas dirige fotograma a fotograma conargs.allowed.
Lo que queda en tu aplicación
La librería hace cumplir lo que configuras e informa de lo que ocurre. Tu aplicación es dueña de las reglas en sí (qué recurso acepta qué trabajo, quién puede cambiar qué), de la validación en el servidor de cada cambio y de cualquier colocación u optimización automática. Mover una reserva nunca replanifica las demás: si tu negocio necesita cambios en cascada, calcúlalos y actualiza tú los eventos.
Citas de clínica de fisioterapiaUn paciente no puede venir a las 10:00. Encuentra el siguiente hueco que respete pausas y limpiezas. Planificación de órdenes de fabricaciónEl mantenimiento se ha adelantado. Aparta la orden y mantén sus operaciones en secuencia. Reservas de pistas en un club deportivoA media mañana se rompe la red de una pista de pádel. Mueve la clase, cierra la pista y respeta el turno de cada monitor.
Siguientes pasos
- Persiste los cambios que aceptan estas reglas: Eventos controlados y callbacks.
- Permite a los usuarios deshacer un movimiento: Deshacer y rehacer.
- Aplica las mismas reglas desde el teclado: Teclado, accesibilidad y táctil.
Ejemplos relacionados
- FormaCitas de clínica de fisioterapiaUn paciente no puede venir a las 10:00. Encuentra el siguiente hueco que respete pausas y limpiezas.
- ForgePlanificación de órdenes de fabricaciónEl mantenimiento se ha adelantado. Aparta la orden y mantén sus operaciones en secuencia.
- Court ClubReservas de pistas en un club deportivoA media mañana se rompe la red de una pista de pádel. Mueve la clase, cierra la pista y respeta el turno de cada monitor.
- FieldworkDespacho de servicio técnicoEntra un trabajo urgente en la cola. Encuentra la cuadrilla que puede atenderlo a tiempo.