# Paneles coordinados y vistas guardadas

> Divide la línea de tiempo en paneles con super-scheduler/panes, mueve eventos entre ellos y guarda y restaura zoom, scroll y filas con super-scheduler/views.

Source: https://superscheduler.org/es/docs/panes-saved-views/
Reviewed: 2026-10-07

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.

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`.

```tsx
// src/RoomsWithTray.tsx
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
| Campo | Por defecto | Efecto |
|---|---|---|
| `id` | obligatorio | Identifica el panel en los handlers (`args.pane`), en `panesRef` y en el DOM (`data-pane`) |
| `resources` | | Las filas de este panel |
| `rowFilter` | | Elige 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 |
| `minSize` | `48` | Altura mínima en píxeles; los mínimos prevalecen cuando el total es demasiado pequeño |
| `hidden` | `false` | Oculta el panel pero lo mantiene montado, así que volver a mostrarlo no cuesta nada |
| `props` | | Props 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
| Prop | Por defecto | Efecto |
|---|---|---|
| `height` | obligatorio | Altura 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'` |
| `splitter` | `true` | `{ 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.

> **Behavior:**
> Las alturas que ajusta el usuario duran hasta que cambian `panes` o `height`. Define `panes` a nivel de módulo o memoízalo, como hace el fragmento; un array nuevo en cada render reiniciaría el divisor. Para recordar los tamaños entre sesiones, guárdalos desde `onPaneResize` y devuélvelos como `size` de cada panel.

## 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](https://superscheduler.org/es/docs/controlled-state/).
- **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](https://superscheduler.org/es/docs/react-render-slots/) 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ú.

### Vincular schedulers que colocas 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`:

```tsx
// src/LinkedBoards.tsx
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 `include` | Campos guardados | Notas |
|---|---|---|
| `'zoom'` | `cellWidth`, `zoomLevel` | `zoomLevel` es el índice del nivel activo en `zoomLevels`, así que mantén estable su orden |
| `'scroll'` | `anchorDate`, `topRowId`, `topOffset` | La fecha del borde izquierdo y la fila superior, por id, con el desplazamiento dentro de ella |
| `'density'` | `density` | Solo cuando defines la prop `density` |
| `'collapsed'` | `collapsed` | Ids de los padres del árbol que están plegados |
| `'columns'` | `columnWidths`, `columnOrder` | Ancho y orden de las columnas de la cabecera de fila |

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

```ts
// src/savedView.ts
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 })
}
```
```tsx
// src/PlannerWithViews.tsx
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.

> **Tip:**
> Si tu aplicación guarda `density` o `rowHeaderColumns` en el estado de React, restáuralos a través de tu estado y déjalos fuera de `include`, como hace el fragmento. `applyViewState` los cambia en el control, y un cambio de prop posterior desde React lo sobrescribiría.

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.

## Relacionado
→ https://superscheduler.org/es/examples/training-rooms/
- [Árboles de recursos, columnas y selección](https://superscheduler.org/es/docs/trees-columns-selection/) para las filas plegadas y las columnas que guarda una vista.
- [Escalas de tiempo y zoom](https://superscheduler.org/es/docs/time-scales-zoom/) para los niveles de zoom que restaura una vista.
