Saltar al contenido
SuperScheduler

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().

Verificado con v0.1.0 · revisado el 7 de octubre de 2026.md

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

  1. El usuario agarra un evento. Los eventos bloqueados (moveDisabled) no inician un arrastre.
  2. En cada movimiento del puntero que cambia la hora o la fila de destino, se ejecuta onEventMoving (onEventResizing al redimensionar). Tu regla fija args.allowed, puede ajustar args.start y args.end, y fija args.message.
  3. Después, la librería aplica sus propias comprobaciones: el solape con otros eventos cuando allowEventOverlap es false y las celdas deshabilitadas. Una sombra rechazada se dibuja como prohibida y la tarjeta de arrastre muestra el motivo.
  4. 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.
  5. 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.
  6. 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:

EscribibleEfecto
allowedfalse dibuja la sombra como prohibida; soltar ahí no hace nada
messageTexto que muestra la tarjeta de arrastre mientras allowed es false
start, endAjustan la sombra, por ejemplo para conservar las horas originales cuando solo cambia la fila
allowOverlapSustituye a allowEventOverlap solo en este fotograma
cssClass, htmlClase 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').

src/WorkshopPlanning.tsxtsx
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.allowOverlap en onEventMoving y onEventResizing sustituye a la opción en el fotograma actual, como hace la lista de espera de arriba. Se restablece en cada llamada.
  • args.conflicts enumera los eventos existentes con los que choca la sombra en la fila de destino (hasta ocho), como envoltorios SuperScheduler.Event. Úsalo para distinguir los conflictos leves de los graves:
src/soft-conflicts.tsts
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: true en el recurso, para un box en reparación o una habitación fuera de servicio.
  • Cualquier celda: pon args.cell.properties.disabled = true en onBeforeCellRender, para pausas de comida, festivos u horarios de apertura por recurso.
src/ClosedTime.tsxtsx
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 eventoEfecto
moveDisabledEl evento no se puede mover
resizeDisabledEl evento no se puede redimensionar
moveHDisabledPuede cambiar de fila, pero no de hora
moveVDisabledPuede cambiar de hora, pero no de fila
clickDisabled, deleteDisabledIgnora 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.newEnd o args.newResource.
  • Aplazar con args.async = true y después llamar a args.loaded() cuando tengas una respuesta. Llamar a args.preventDefault() antes de loaded() cancela la operación.
src/confirm-move.tsts
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:

src/drag-card.tsts
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:

src/CreateOnSelect.tsxtsx
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 onTimeRangeSelected con origin: 'click' y una celda. Comprueba origin === '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 allowEventOverlap es false. onTimeRangeSelecting las dirige fotograma a fotograma con args.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