Aller au contenu
SuperScheduler

InteractionS’applique àSuperScheduler Pro

Glisser, redimensionner et règles métier

Pilotez chaque frame du glisser dans onEventMoving et onEventResizing : définissez args.allowed = false et args.message pour refuser une position en donnant une explication. Empêchez les chevauchements avec allowEventOverlap={false} (ou frame par frame avec args.allowOverlap), bloquez du temps avec des cellules désactivées, verrouillez des événements isolés avec moveDisabled et resizeDisabled, et prenez la décision finale dans onEventMove ou onEventResize, en asynchrone si nécessaire avec args.async = true et args.loaded().

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

C’est au glisser qu’un planning prouve son utilité, et c’est là que vivent la plupart des règles métier : cette intervention nécessite un pont élévateur, ce séjour ne peut pas être déplacé dans le passé, le bloc opératoire ferme à midi. SuperScheduler Pro consulte votre code à deux moments. Pendant le glisser, à chaque changement de l’ombre, vous pouvez accepter, refuser ou ajuster la position, et dire pourquoi. Au dépôt, une seule fois, vous pouvez annuler, modifier ou confirmer le changement, y compris après un aller-retour avec votre serveur.

Ce guide construit ces règles sur le planning d’un atelier avec des postes de travail, puis traite des chevauchements, du temps fermé, des verrous, de la confirmation asynchrone, de la carte de glisser et de la création d’événements par sélection d’une plage. Tout ce qui suit nécessite Pro ; Lite est en lecture seule.

Comment se décide un glisser

  1. L’utilisateur saisit un événement. Les événements verrouillés (moveDisabled) ne déclenchent pas de glisser.
  2. À chaque mouvement du pointeur qui change l’heure ou la ligne cible, onEventMoving s’exécute (onEventResizing pour un redimensionnement). Votre règle définit args.allowed, peut ajuster args.start et args.end, et définit args.message.
  3. La bibliothèque applique ensuite ses propres contrôles : chevauchement avec d’autres événements quand allowEventOverlap vaut false, et cellules désactivées. Une ombre refusée est dessinée comme interdite et la carte de glisser affiche le motif.
  4. Au relâchement sur une position refusée, il ne se passe rien : l’événement revient à sa place et aucun autre callback ne s’exécute.
  5. Au relâchement sur une position acceptée, onEventMove (onEventResize) s’exécute une fois, avant la modification du stockage. Il peut annuler, modifier ou différer la validation.
  6. Le stockage est mis à jour, onEventMoved (onEventResized) s’exécute, et onEventsChange suit à la microtâche suivante, comme décrit dans Événements contrôlés et callbacks.

La même séquence de validation s’applique aux déplacements et redimensionnements effectués au clavier avec keyboardMode: 'Full'.

Valider pendant le glisser

onEventMoving reçoit la position candidate et inscrit la décision dans ses arguments :

ModifiableEffet
allowedfalse dessine l’ombre comme interdite ; un dépôt à cet endroit ne fait rien
messageTexte affiché par la carte de glisser tant que allowed vaut false
start, endAjustent l’ombre, par exemple pour conserver les horaires d’origine quand seule la ligne change
allowOverlapRemplace allowEventOverlap pour cette frame uniquement
cssClass, htmlClasse et contenu de l’ombre

Il lit aussi le contexte : args.e (l’événement déplacé, avec vos données dans args.e.data), args.resource et args.row (la ligne cible), args.duration, args.conflicts, args.external (glissé depuis l’extérieur du planificateur) et les touches de modification. onEventResizing fonctionne de la même façon sur start, end, allowed, message et allowOverlap, et ajoute args.what, le bord en cours de déplacement ('start' ou '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}
    />
  )
}

Vous devriez voir un planning de cinq jours en cellules d’une demi-heure, de 08:00 à 18:00. Faites glisser une intervention qui nécessite un pont élévateur sur Bay 3 : l’ombre devient interdite et la carte indique « This job needs a bay with a lift. ». Faites glisser n’importe quelle intervention sur une autre dans un poste : elle est refusée pour chevauchement. Déposez-la sur la liste d’attente : les deux interventions s’y empilent. Raccourcissez une intervention en dessous de 30 minutes : le redimensionnement est refusé.

Empêcher les chevauchements

allowEventOverlap={false} refuse tout déplacement, redimensionnement ou sélection de plage qui chevaucherait un autre événement de la même ligne. Les intervalles sont semi-ouverts : des événements bout à bout (l’un se termine à 11:00, le suivant commence à 11:00) ne comptent jamais comme un chevauchement.

Deux outils affinent la règle selon la situation :

  • args.allowOverlap dans onEventMoving et onEventResizing remplace l’option pour la frame en cours, comme le fait la liste d’attente ci-dessus. Il est réinitialisé à chaque appel.
  • args.conflicts liste les événements existants avec lesquels l’ombre entre en collision dans la ligne cible (huit au maximum), sous forme d’objets enveloppes SuperScheduler.Event. Utilisez-le pour distinguer les conflits tolérables des conflits bloquants :
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 entoure d’un contour les événements en collision pendant le glisser (ils reçoivent un attribut data-conflict et un contour de couleur d’alerte) : l’utilisateur voit ce qui gêne, et pas seulement que quelque chose gêne.

Bloquer du temps avec des cellules désactivées

Une cellule désactivée est dessinée hachurée et refuse les déplacements, redimensionnements et sélections de plage qui la touchent. Il y a deux façons de désactiver des cellules :

  • Une ligne entière : cellsDisabled: true sur la ressource, pour un poste en réparation ou une chambre hors service.
  • N’importe quelle cellule : définissez args.cell.properties.disabled = true dans onBeforeCellRender, pour les pauses déjeuner, les jours fériés ou les horaires d’ouverture propres à chaque ressource.
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}
    />
  )
}

Vous devriez voir Bay 3 hachuré sur toute sa ligne et tous les postes hachurés de 13:00 à 14:00. Les interventions ne peuvent pas être déposées ni étirées à travers la pause déjeuner.

Autres façons d’exprimer le temps fermé : le masquer de l’axe avec showNonBusiness={false} ou onIncludeTimeCell (voir Heures, minutes, jours et zoom), ou le représenter par des événements qui ne peuvent être ni déplacés ni redimensionnés (moveDisabled, resizeDisabled), comme un blocage de maintenance, qui comptent aussi comme chevauchements quand allowEventOverlap vaut false.

Verrouiller des événements isolés

Champ de l’événementEffet
moveDisabledL’événement ne peut pas être déplacé
resizeDisabledL’événement ne peut pas être redimensionné
moveHDisabledIl peut changer de ligne mais pas d’horaire
moveVDisabledIl peut changer d’horaire mais pas de ligne
clickDisabled, deleteDisabledIl ignore les clics, ou n’a pas de bouton de suppression

Pour l’ensemble du planificateur, eventMoveHandling="Disabled" et eventResizeHandling="Disabled" désactivent les gestes. Les règles qui dépendent de la personne qui regarde (un réceptionniste peut déplacer, un client non) sont des permissions : calculez ces champs à partir du rôle de l’utilisateur avant de passer les événements.

Confirmer au dépôt, y compris en asynchrone

onEventMove et onEventResize s’exécutent une fois par dépôt, avant toute modification. Ils peuvent :

  • Annuler avec args.preventDefault().
  • Modifier le résultat en affectant args.newStart, args.newEnd ou args.newResource.
  • Différer avec args.async = true, puis appeler args.loaded() une fois la réponse obtenue. Appeler args.preventDefault() avant loaded() annule le dépôt.
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())
}

Branchez-les avec onEventMove={confirmMove} et onEventResize={confirmResize}. Tant que la décision est en attente, l’événement reste à sa position d’origine ; il se déplace quand loaded() termine le dépôt, ou reste en place si le dépôt a été annulé.

La carte de glisser

Pendant le glisser, une carte à côté du pointeur affiche les dates cibles, la durée (en nuits pour les plages en jours entiers, en heures et minutes sinon), la ligne cible et la raison d’un refus : votre args.message, ou l’événement chevauché. Un repère de date apparaît aussi dans l’en-tête de temps (headerMarker). Les deux sont activés par défaut.

Configurez la carte avec un objet stable :

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}`
  },
}

Passez dragCard={DRAG_CARD}, ou dragCard={false} pour la supprimer. Les libellés par défaut sont traduits en anglais, espagnol, catalan, basque, galicien, allemand, français, italien et portugais, selon la locale du planificateur ; labels les remplace. L’option html reçoit les dates, le nom de la ligne, le premier conflit, le message de refus et la position de l’événement avant le glisser.

Créer des événements en sélectionnant du temps

Faire glisser sur des cellules vides sélectionne une plage de temps ; onTimeRangeSelected la signale avec start, end (exclusif), resource et origin. Y créer un événement est une décision de votre application :

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

Vous devriez voir une ombre de sélection suivre le pointeur, refuser de dépasser quatre heures ou de recouvrir une autre intervention, puis devenir un événement « New job » au relâchement.

Trois comportements à connaître :

  • Un simple clic est aussi une sélection. Cliquer sur une cellule vide déclenche onTimeRangeSelected avec origin: 'click' et une seule cellule. Vérifiez origin === 'drag' si un clic ne doit rien créer.
  • La sélection reste visible après le relâchement, jusqu’à la sélection suivante ou jusqu’à args.control.clearSelection().
  • Les sélections suivent les mêmes règles que les déplacements : elles ne peuvent traverser ni des cellules désactivées, ni du temps occupé quand allowEventOverlap vaut false. onTimeRangeSelecting les pilote frame par frame avec args.allowed.

Ce qui reste dans votre application

La bibliothèque fait respecter ce que vous configurez et signale ce qui se passe. Votre application possède les règles elles-mêmes (quelle ressource accepte quel travail, qui peut modifier quoi), la validation côté serveur de chaque modification, ainsi que tout placement automatique ou toute optimisation. Déplacer une réservation ne replanifie jamais les autres : si votre métier exige des modifications en cascade, calculez-les et mettez à jour les événements vous-même.

Rendez-vous d’un cabinet de kinésithérapieUn patient ne peut pas venir à 10 h. Trouvez le prochain créneau qui respecte pauses et nettoyages. Ordonnancement des ordres de fabricationLa maintenance a été avancée. Déplacez l’ordre et gardez ses opérations dans le bon ordre. Réservation de courts dans un club sportifEn pleine matinée, le filet d’un court de padel lâche. Déplacez le stage, fermez le court et gardez chaque coach dans son service.

Étapes suivantes