Aller au contenu
SuperScheduler

Modules ProS’applique àSuperScheduler Pro

Volets coordonnés et vues enregistrées

Remplacez SuperSchedulerComponent par SchedulerPanes, importé de super-scheduler/panes, et décrivez chaque volet avec un id plus resources ou un rowFilter ; les volets partagent le défilement horizontal, le zoom et la largeur de l’en-tête de ligne, défilent verticalement chacun de leur côté, et les événements peuvent être glissés de l’un à l’autre. Pour les vues enregistrées, getViewState(control) renvoie un objet sérialisable en JSON avec le zoom, la position de défilement, la densité, les lignes repliées et les colonnes, et applyViewState le restaure ; l’endroit où il est stocké relève de votre application.

Vérifié avec la v0.1.0 · relu le 7 octobre 2026.md

Deux besoins reviennent dans tout grand écran de planification. Le premier : garder une partie des lignes visible pendant que le reste défile, comme une zone « à affecter » sous les chambres ou une équipe au-dessus de ses machines. Le second : retrouver plus tard la même vue, c’est-à-dire le zoom, la date et les lignes que l’utilisateur regardait. super-scheduler/panes et super-scheduler/views y répondent, et tous deux nécessitent SuperScheduler Pro.

Découper une frise en volets

SchedulerPanes affiche plusieurs planificateurs empilés sur une même frise. Ils partagent la position de défilement horizontal, le zoom et la largeur de l’en-tête de ligne ; chaque volet défile verticalement de son côté et a sa propre hauteur. Des séparateurs entre les volets permettent de les redimensionner.

Il remplace SuperSchedulerComponent : vous passez une seule fois les mêmes props de planificateur, plus un tableau panes et une hauteur totale 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}
      />
    </>
  )
}

Vous devriez voir les chambres en haut et, en dessous, une zone « à affecter » de 140 px, avec un seul en-tête de temps tout en haut. Faites défiler l’un des volets latéralement : l’autre suit. Glissez une réservation de la zone vers une chambre : elle s’y déplace ; glissez-en une d’une chambre vers la zone : l’application demande d’abord confirmation.

Options des volets

ChampDéfautEffet
idobligatoireIdentifie le volet dans les handlers (args.pane), dans panesRef et dans le DOM (data-pane)
resourcesLes lignes de ce volet
rowFilterChoisit les lignes de ce volet parmi les resources partagées ; utilisez soit cette option, soit resources
size'auto'Des pixels, un pourcentage de la hauteur libre ('30%'), ou 'auto' pour une part de ce qui reste
minSize48Hauteur minimale en pixels ; les minimums l’emportent quand le total est trop petit
hiddenfalseMasque le volet mais le garde monté : le réafficher ne coûte rien
propsProps propres à ce volet ; les handlers définis ici remplacent les handlers partagés

Les lignes sont réparties par ressource de premier niveau : un parent emmène ses enfants dans son volet. Le premier volet qui n’a ni resources ni rowFilter reçoit toutes les ressources de premier niveau que les autres volets n’ont pas prises.

Disposition et séparateur

PropDéfautEffet
heightobligatoireHauteur totale en pixels : tous les volets, les séparateurs et l’en-tête partagé
timeHeader'first''first' n’affiche l’en-tête de temps que sur le premier volet visible ; 'all' sur chaque volet
scrollbar'last'Barre de défilement horizontale sur le dernier volet seulement, ou 'all'
splittertrue{ size, step } définit son épaisseur (6 px) et son pas au clavier (8 px) ; false le supprime
onPaneResize{ sizes } par id de volet, après validation d’un redimensionnement

Le séparateur est focalisable, avec role="separator" ; sa valeur est la hauteur du volet situé en dessous. Haut et Bas le déplacent de step, Maj+Haut et Maj+Bas de 40 px, Début et Fin l’amènent aux limites, et Entrée ou un double-clic rétablit les tailles déclarées. Pendant le glissement, les volets sont prévisualisés ; leurs hauteurs changent au relâchement. Dans la 0.1.0, son nom accessible est l’anglais « Pane size », sans option pour le traduire.

Les événements dans les volets

Passez tous les événements une seule fois. Chaque volet affiche les événements dont la resource est l’une de ses lignes, et un événement passe dans un autre volet quand sa ressource y passe.

  • Contrôlé : events plus onEventsChange. Le handler reçoit la liste complète et fusionnée dans args.events, ainsi que args.pane pour le volet où la modification a eu lieu. Adoptez-la comme dans l’état contrôlé.
  • Non contrôlé : defaultEvents, et les volets gèrent la liste eux-mêmes.

Modifier directement le control.events.list d’un volet n’est pas répercuté sur les autres volets ; passez par l’état ou par l’API control.events.

Déplacements entre volets

Le glissement entre volets est activé par défaut (crossPaneMove: true) ; false garde chaque événement dans son volet. Avec la valeur par défaut eventMoveHandling: 'Update', un déplacement entre volets est signalé une seule fois, comme une modification 'move' dans onEventsChange.

Chaque handler partagé reçoit args.pane. Lors d’un déplacement entre volets, onEventMove et onEventMoved reçoivent aussi args.sourcePane, si bien qu’une règle peut dépendre du sens : l’extrait ne demande confirmation que pour les déplacements des chambres vers la zone, avec args.async et args.loaded(). Un déplacement annulé ou refusé laisse les données inchangées.

Accéder au contrôle de chaque volet

SchedulerPanes crée les planificateurs : il vous donne donc leurs contrôles via panesRef :

  • controls : une map de l’id du volet vers son contrôle, et control(id) pour l’un d’eux ;
  • forEach(run) pour appeler quelque chose sur chaque volet ;
  • scrollTo(date, position) pour les faire défiler ensemble ;
  • update(options) pour appliquer des options à chaque volet.

Pour utiliser les slots de rendu React dans les volets, passez le composant de ce point d’entrée : component={SuperSchedulerComponent}, importé de super-scheduler/react-render. Le module des volets ne l’importe que si vous le faites.

Quand les planificateurs ne sont pas empilés (un planning du personnel en haut de la page et un planning des salles plus bas), gardez vos propres composants et reliez leurs contrôles avec 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}
      />
    </>
  )
}

Le défilement horizontal est toujours partagé. Le zoom et la largeur de l’en-tête de ligne le sont aussi, sauf si vous passez zoom: false ou rowHeaderWidth: false. Appelez dispose() pour défaire le lien.

Enregistrer et restaurer une vue

Une vue, c’est la façon dont l’utilisateur regarde les données, pas les données elles-mêmes. getViewState(control, include?) la capture sous forme d’un petit objet sérialisable en JSON ; applyViewState(control, state, options?) la restaure.

Clé de includeChamps enregistrésRemarques
'zoom'cellWidth, zoomLevelzoomLevel est l’index du niveau actif dans zoomLevels : gardez leur ordre stable
'scroll'anchorDate, topRowId, topOffsetLa date au bord gauche et la ligne du haut, par id, avec le décalage à l’intérieur de celle-ci
'density'densitySeulement si vous définissez la prop density
'collapsed'collapsedId des parents d’arborescence repliés
'columns'columnWidths, columnOrderLargeur et ordre des colonnes de l’en-tête de ligne

Chaque état comporte v: 1. Sans include, les cinq clés sont capturées.

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}
      />
    </>
  )
}

Faites défiler jusqu’à une date, repliez un étage, appuyez sur « Save this view » et rechargez la page : le planning revient à la même date et à la même ligne, avec l’étage replié.

Comment se passe la restauration :

  • when: 'rows' (par défaut) attend que les lignes, et la ligne du haut enregistrée, existent ; cela couvre les données qui arrivent après le montage. Si elles n’apparaissent pas dans le délai timeout (5 000 ms par défaut), la promesse se résout avec false.
  • when: 'now' applique immédiatement ; si la ligne du haut enregistrée a disparu, le décalage enregistré sert de position de défilement absolue.
  • animate: true anime le changement de zoom.
  • Les parents listés dans collapsed sont repliés et tous les autres parents sont dépliés.
  • Si les colonnes enregistrées ne correspondent plus à rowHeaderColumns (un nombre de colonnes différent), rien n’est appliqué et la promesse se résout avec false. Un état d’une version autre que 1 se résout aussi avec false.
  • La restauration ne déplace pas le focus clavier.

Avec des volets, enregistrez et restaurez via le contrôle d’un seul volet (panesRef.current?.control('rooms')) : le zoom et le défilement horizontal sont partagés, tandis que le défilement vertical et les lignes repliées appartiennent à ce volet.

Ce qui revient à votre application

  • Le stockage. localStorage pour un seul navigateur, ou votre backend pour suivre l’utilisateur d’un appareil à l’autre. La bibliothèque ne stocke jamais rien.
  • Le nommage et le partage. Vues nommées, vues par défaut par équipe, liens qui ouvrent une vue.
  • La validation. Les vues stockées sont des entrées non fiables : vérifiez leur forme et v avant de les appliquer, et écartez celles qui échouent.
  • Des id stables. Les id de lignes doivent désigner les mêmes lignes d’une session à l’autre pour que topRowId et collapsed fonctionnent.
  • Tailles des volets et sélections. Ni les unes ni les autres ne font partie d’une vue ; stockez les tailles des volets depuis onPaneResize si vous voulez les retrouver.

Planification de salles de formationLes inscriptions dépassent la salle. Sélectionnez les deux sessions, voyez ce qui est libre pour les deux, déplacez-les ensemble et gardez la vue.