Saltar al contenido
SuperScheduler

Módulos ProSe aplica aSuperScheduler Pro

Paneles coordinados y vistas guardadas

Sustituye SuperSchedulerComponent por SchedulerPanes de super-scheduler/panes y describe cada panel con un id y resources o un rowFilter; los paneles comparten el scroll horizontal, el zoom y el ancho de la cabecera de fila, se desplazan en vertical cada uno por su cuenta, y los eventos se pueden arrastrar de uno a otro. Para las vistas guardadas, getViewState(control) devuelve un objeto apto para JSON con el zoom, la posición de scroll, la densidad, las filas plegadas y las columnas, y applyViewState lo restaura; dónde se guarda lo decide tu aplicación.

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

En cualquier pantalla de planificación grande aparecen dos necesidades. La primera es mantener parte de las filas a la vista mientras el resto se desplaza: una bandeja de «sin asignar» bajo las habitaciones, un equipo encima de sus máquinas. La segunda es volver más tarde a la misma vista: el zoom, la fecha y las filas que el usuario estaba mirando. super-scheduler/panes y super-scheduler/views las cubren, y los dos requieren SuperScheduler Pro.

Dividir una línea de tiempo en paneles

SchedulerPanes renderiza varios schedulers apilados sobre una misma línea de tiempo. Comparten la posición de scroll horizontal, el zoom y el ancho de la cabecera de fila; cada panel se desplaza en vertical por su cuenta y tiene su propia altura. Los divisores entre paneles permiten cambiar su tamaño.

Sustituye a SuperSchedulerComponent: pasas una sola vez las mismas props del scheduler, más un array panes y una altura total height.

src/RoomsWithTray.tsxtsx
import { useCallback, useMemo, useRef, useState } from 'react'
import type { SchedulerEventsChangeArgs, SchedulerProps, SuperScheduler } from 'super-scheduler'
import { SchedulerPanes } from 'super-scheduler/panes'
import type { SchedulerPane, SchedulerPanesHandle } from 'super-scheduler/panes'
import 'super-scheduler/styles.css'

const isTray = (resource: SuperScheduler.ResourceData) => resource.kind === 'tray'

// Module-level (or memoized): a new `panes` array resets the sizes the user dragged.
const PANES: SchedulerPane[] = [
  { id: 'rooms', rowFilter: (resource) => !isTray(resource), minSize: 200 },
  {
    id: 'tray',
    rowFilter: isTray,
    size: 140,
    minSize: 96,
    // Per-pane overrides: smaller events in the unassigned tray.
    props: { eventHeight: 28 },
  },
]

export function RoomsWithTray(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const panesRef = useRef<SchedulerPanesHandle>(null)

  // One list for every pane: each event appears in the pane that holds its resource.
  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const shared = useMemo<Partial<SchedulerProps>>(
    () => ({
      onEventMove: (args) => {
        // `pane` is where the event lands; `sourcePane` is set only for a move between panes.
        if (args.sourcePane !== 'rooms' || args.pane !== 'tray') return
        // Unassigning a booking asks first; the drop waits for the answer.
        args.async = true
        void confirmWithUser(`Unassign ${args.e.text()}?`).then((ok) => {
          if (!ok) args.preventDefault()
          args.loaded()
        })
      },
    }),
    [],
  )

  return (
    <>
      <button type="button" onClick={() => panesRef.current?.scrollTo('2026-10-01', 'left')}>
        Go to 1 October
      </button>
      <SchedulerPanes
        {...shared}
        panesRef={panesRef}
        panes={PANES}
        // Total height of every pane, the splitter and the shared header.
        height={640}
        resources={props.resources}
        events={events}
        onEventsChange={onEventsChange}
        splitter={{ size: 6, step: 8 }}
        startDate="2026-10-01"
        days={60}
        scale="Day"
        cellWidth={44}
      />
    </>
  )
}

Deberías ver las habitaciones arriba y, debajo, una bandeja de «sin asignar» de 140 px, con una sola cabecera de tiempo en la parte superior. Desplaza cualquiera de los paneles en horizontal y el otro lo sigue. Arrastra una reserva de la bandeja a una habitación y se mueve allí; arrastra una de una habitación a la bandeja y la aplicación pregunta antes.

Opciones de cada panel

CampoPor defectoEfecto
idobligatorioIdentifica el panel en los handlers (args.pane), en panesRef y en el DOM (data-pane)
resourcesLas filas de este panel
rowFilterElige las filas de este panel entre los resources compartidos; usa esto o resources, no ambos
size'auto'Píxeles, un porcentaje de la altura libre ('30%') o 'auto' para una parte de lo que queda
minSize48Altura mínima en píxeles; los mínimos prevalecen cuando el total es demasiado pequeño
hiddenfalseOculta el panel pero lo mantiene montado, así que volver a mostrarlo no cuesta nada
propsProps solo para este panel; los handlers definidos aquí sustituyen a los compartidos

Las filas se asignan por recurso de nivel superior: un padre se lleva a sus hijos a su panel. El primer panel sin resources ni rowFilter recibe todos los recursos de nivel superior que no se han llevado los demás paneles.

Disposición y divisor

PropPor defectoEfecto
heightobligatorioAltura total en píxeles: todos los paneles, los divisores y la cabecera compartida
timeHeader'first''first' muestra la cabecera de tiempo solo en el primer panel visible; 'all', en todos los paneles
scrollbar'last'Barra de scroll horizontal solo en el último panel, o 'all'
splittertrue{ size, step } fija su grosor (6 px) y su paso con el teclado (8 px); false lo elimina
onPaneResize{ sizes } por id de panel, después de confirmar un cambio de tamaño

El divisor es enfocable y tiene role="separator"; su valor es la altura del panel que queda debajo. Flecha arriba y Flecha abajo lo mueven step píxeles, Mayús+Flecha arriba y Mayús+Flecha abajo, 40 px; Inicio y Fin lo llevan a los límites, e Intro o un doble clic restauran los tamaños declarados. Mientras arrastras, se muestra una vista previa de los paneles; sus alturas cambian al soltar. En 0.1.0, su nombre accesible es el inglés «Pane size», sin ninguna opción para traducirlo.

Eventos en paneles

Pasa todos los eventos una sola vez. Cada panel muestra los eventos cuyo resource es una de sus filas, y un evento pasa a otro panel cuando su recurso lo hace.

  • Controlados: events más onEventsChange. El handler recibe la lista completa y combinada en args.events, y args.pane con el panel donde se produjo el cambio. Adóptala como en estado controlado.
  • No controlados: defaultEvents, y los paneles mantienen la lista por su cuenta.

Cambiar directamente el control.events.list de un panel no se comparte con los demás paneles; hazlo a través del estado o de la API control.events.

Movimientos entre paneles

Arrastrar entre paneles está activado por defecto (crossPaneMove: true); false mantiene cada evento en su panel. Con el valor por defecto eventMoveHandling: 'Update', un movimiento entre paneles se comunica una sola vez, como un cambio 'move' en onEventsChange.

Todos los handlers compartidos reciben args.pane. En un movimiento entre paneles, onEventMove y onEventMoved reciben además args.sourcePane, así que una regla puede depender de la dirección: el fragmento pide confirmación solo para los movimientos de las habitaciones a la bandeja, con args.async y args.loaded(). Un movimiento cancelado o rechazado deja los datos sin cambios.

Acceder al control de cada panel

SchedulerPanes crea los schedulers, así que te da sus controles a través de panesRef:

  • controls: un mapa del id de panel al control, y control(id) para obtener uno de ellos;
  • forEach(run) para llamar a algo en todos los paneles;
  • scrollTo(date, position) para desplazarlos juntos;
  • update(options) para aplicar opciones a todos los paneles.

Para usar los slots de renderizado React dentro de los paneles, pasa el componente de ese punto de entrada: component={SuperSchedulerComponent} importado de super-scheduler/react-render. El módulo de paneles no lo importa a menos que lo hagas tú.

Cuando los schedulers no están apilados (un plan de personal en la parte superior de la página y un plan de salas más abajo), conserva tus propios componentes y vincula sus controles con linkPanes:

src/LinkedBoards.tsxtsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { linkPanes } from 'super-scheduler/panes'

// Two schedulers placed by your own layout (here, a page section apart) that move together.
export function LinkedBoards(props: {
  staff: SuperScheduler.ResourceData[]
  rooms: SuperScheduler.ResourceData[]
  shifts: SuperScheduler.EventData[]
  bookings: SuperScheduler.EventData[]
}) {
  const staff = useSchedulerControl()
  const rooms = useSchedulerControl()
  const shifts = useMemo(() => props.shifts.slice(), [props.shifts])
  const bookings = useMemo(() => props.bookings.slice(), [props.bookings])

  useEffect(() => {
    if (staff.control === null || rooms.control === null) return
    // Horizontal scroll always; zoom and row header width too unless turned off.
    const link = linkPanes([staff.control, rooms.control], { zoom: true, rowHeaderWidth: true })
    return () => link.dispose()
  }, [staff.control, rooms.control])

  return (
    <>
      <h2>Staff</h2>
      <SuperSchedulerComponent
        controlRef={staff.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.staff}
        events={shifts}
      />
      <h2>Rooms</h2>
      <SuperSchedulerComponent
        controlRef={rooms.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.rooms}
        events={bookings}
      />
    </>
  )
}

El scroll horizontal siempre se comparte. El zoom y el ancho de la cabecera de fila se comparten salvo que pases zoom: false o rowHeaderWidth: false. Llama a dispose() para desvincularlos.

Guardar y restaurar una vista

Una vista es cómo el usuario está mirando los datos, no los datos en sí. getViewState(control, include?) la captura como un objeto pequeño y apto para JSON; applyViewState(control, state, options?) la restaura.

Clave en includeCampos guardadosNotas
'zoom'cellWidth, zoomLevelzoomLevel es el índice del nivel activo en zoomLevels, así que mantén estable su orden
'scroll'anchorDate, topRowId, topOffsetLa fecha del borde izquierdo y la fila superior, por id, con el desplazamiento dentro de ella
'density'densitySolo cuando defines la prop density
'collapsed'collapsedIds de los padres del árbol que están plegados
'columns'columnWidths, columnOrderAncho y orden de las columnas de la cabecera de fila

Todos los estados tienen v: 1. Sin include, se capturan las cinco claves.

src/savedView.tsts
import type { SuperScheduler } from 'super-scheduler'
import { applyViewState, getViewState } from 'super-scheduler/views'
import type { SchedulerViewState, ViewStateKey } from 'super-scheduler/views'

// What this application restores from the view. Density and columns stay in React state here.
const KEYS: readonly ViewStateKey[] = ['zoom', 'scroll', 'collapsed']

const storageKey = (user: string, view: string) => `planning-view:${user}:${view}`

/** Saves the current view. The application owns storage: here localStorage, per user. */
export function saveView(control: SuperScheduler.Scheduler, user: string, view: string): void {
  const state = getViewState(control, KEYS)
  try {
    localStorage.setItem(storageKey(user, view), JSON.stringify(state))
  } catch {
    // Storage can be full or disabled; a view is a convenience, not data.
  }
}

/** Stored values are untrusted input: check the shape before using them. */
function isViewState(value: unknown): value is SchedulerViewState {
  return typeof value === 'object' && value !== null && (value as { v?: unknown }).v === 1
}

/** Restores a saved view. Resolves false when nothing was saved or the rows never appeared. */
export async function restoreView(
  control: SuperScheduler.Scheduler,
  user: string,
  view: string,
): Promise<boolean> {
  let saved: unknown = null
  try {
    saved = JSON.parse(localStorage.getItem(storageKey(user, view)) ?? 'null')
  } catch {
    return false
  }
  if (!isViewState(saved)) return false
  // Waits (up to 5 s) for the rows and the saved top row, for data that loads after mount.
  return applyViewState(control, saved, { when: 'rows', timeout: 5000 })
}
src/PlannerWithViews.tsxtsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { restoreView, saveView } from './saved-view'

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'weeks', properties: { scale: 'Week', cellWidth: 120 } },
  { id: 'days', properties: { scale: 'Day', cellWidth: 44 } },
]

export function PlannerWithViews(props: {
  user: string
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const events = useMemo(() => props.events.slice(), [props.events])

  // Restore once the control exists; keep row ids stable so the top row can be found again.
  useEffect(() => {
    if (control !== null) void restoreView(control, props.user, 'default')
  }, [control, props.user])

  return (
    <>
      <button
        type="button"
        disabled={control === null}
        onClick={() => control && saveView(control, props.user, 'default')}
      >
        Save this view
      </button>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-01-01"
        days={365}
        zoomLevels={ZOOM_LEVELS}
        zoom="days"
        treeEnabled
        resources={props.resources}
        events={events}
      />
    </>
  )
}

Desplázate hasta una fecha, pliega una planta, pulsa «Save this view» y recarga: el planificador vuelve a la misma fecha y fila, con la planta plegada.

Cómo funciona la restauración:

  • when: 'rows' (por defecto) espera a que existan las filas y la fila superior guardada; eso cubre los datos que llegan después del montaje. Si no aparecen dentro de timeout (5.000 ms por defecto), la promesa se resuelve con false.
  • when: 'now' aplica el estado de inmediato; si la fila superior guardada ya no existe, el desplazamiento guardado se usa como posición de scroll absoluta.
  • animate: true anima el cambio de zoom.
  • Los padres que figuran en collapsed se pliegan, y todos los demás padres se despliegan.
  • Si las columnas guardadas ya no coinciden con rowHeaderColumns (otro número de columnas), no se aplica nada y la promesa se resuelve con false. Un estado con una versión distinta de 1 también se resuelve con false.
  • Restaurar no mueve el foco del teclado.

Con paneles, guarda y restaura a través del control de un panel (panesRef.current?.control('rooms')): el zoom y el scroll horizontal son compartidos, mientras que el scroll vertical y las filas plegadas pertenecen a ese panel.

Qué le corresponde a tu aplicación

  • El almacenamiento. localStorage para un solo navegador, o tu backend para seguir al usuario entre dispositivos. La librería nunca guarda nada.
  • Nombres y uso compartido. Vistas con nombre, vistas por defecto por equipo, enlaces que abren una vista.
  • La validación. Las vistas guardadas son entrada no fiable: comprueba la forma y v antes de aplicarlas, y descarta las que no pasen.
  • Ids estables. Los ids de fila tienen que corresponder a las mismas filas de una sesión a otra para que topRowId y collapsed funcionen.
  • Tamaños de panel y selecciones. Ninguno de los dos forma parte de una vista; guarda los tamaños de panel desde onPaneResize si quieres recuperarlos.

Planificación de aulas de formaciónLa matrícula superó el aula. Selecciona ambas sesiones, mira qué está libre para las dos, muévelas juntas y conserva la vista.