Aller au contenu
SuperScheduler

LaboratoireBenchlab (Entreprise fictive)

Réservation d’instruments de laboratoire

Réservez un instrument, l’étalonnage est compris. Sortez une séance d’une visite de maintenance, puis allongez votre mesure.

Échelle de temps
Démo avec données fictives
heures / jours
Fonctionnalités Pro
Slots de rendu ReactCréneaux bloquésRègles de déplacementPanneau de détail
Benchlab
MicroscopieMicroscope confocalMicroscope à champ largeAnalyseLC-MSSpectrophotomètre UV-VisBiologie moléculaireThermocycleur qPCR 1Thermocycleur qPCR 2SéquenceurPaillassesPaillasse humide 1Paillasse humide 2Poste de sécurité microbiologiqueCB-091CB-088PX-220PX-214CB-088GN-140TL-02CB-091GN-140mercredi 21 octobrejeudi 22 octobre07:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:0018:0019:0007:0008:0009:0010:0011:0012:0013:0014:0015:00

SuperScheduler Pro n’est chargé que lorsque vous le demandez.

La situation

Benchlab est un laboratoire partagé fictif : deux microscopes, un LC-MS, un spectrophotomètre, des thermocycleurs qPCR, un séquenceur et des paillasses utilisés par quatre équipes. La plupart des instruments demandent du temps avant une mesure : le laser du confocal chauffe, le LC-MS s’étalonne pendant une heure, le séquenceur amorce sa cuve à flux.

Si la réservation n’enregistre que la mesure, la préparation empiète sur le créneau précédent et la visite du fournisseur tombe sur une séance que personne n’a déplacée. Le planning doit traiter la préparation comme une partie de la réservation, mettre le temps indisponible hors de portée et dire clairement pourquoi une demande ne rentre pas. Ces règles sont la politique de réservation de ce laboratoire, pas un système de conformité.

Ce que vous allez faireVous sortirez une séance d’une fenêtre de maintenance, réserverez deux heures de LC-MS avec l’étalonnage ajouté pour vous, puis allongerez cette mesure sans changer l’étalonnage.

Où s’arrête la bibliothèque et où commence votre application

SuperScheduler fournit

  • Niveaux de zoom à l’heure et à la journée, nuits et week-ends masqués.
  • Cellules désactivées qui hachurent les heures indisponibles et refusent les dépôts, définies dans onBeforeCellRender.
  • Zones d’événement ancrées dans le temps, utilisées pour dessiner la préparation dans chaque réservation.
  • Sélection de plages en glissant, glisser et redimensionner sans chevauchement, messages de refus sur la carte de glissement, annuler/rétablir avec événements contrôlés et créations enregistrées.
  • En-têtes de ligne (renderRowHeader) et barres de réservation (renderEvent) en React avec super-scheduler/react-render.

Le code de cet exemple décide

  • La durée de préparation de chaque instrument, et la façon dont une mesure sélectionnée devient une réservation avec la préparation devant.
  • Les changements permis : entre instruments identiques seulement, jamais sur une maintenance, des mesures d’au moins 30 minutes et la préparation fixée au début.
  • Les fenêtres de maintenance et les réservations qu’elles percutent.
  • Le panneau de réservation, la mission et tous les textes de cette page. Aucune promesse réglementaire ou d’audit.

Le code de cette démo

Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.

L’intégration : niveaux de zoom, cellules désactivées pour le temps indisponible, zone de préparation de chaque réservation, réservation en glissant, et les callbacks où les règles refusent un déplacement ou un redimensionnement.

scheduler.tsxtsx
/**
 * Benchlab instrument board: shared instruments and benches over one week, preparation included.
 *
 * Library: timeline with hidden nights, hour and day zoom levels, tree rows, disabled cells for
 * unavailable time, time-anchored event areas for the preparation, drag-to-book range selection,
 * drag/resize with overlap prevention, undo history, keyboard, and React content (row headers and
 * booking bars) through `super-scheduler/react-render`.
 * Application: how long each instrument prepares, where a booking may go, unavailable windows and
 * what a new booking needs in front of its run. Those rules live in ./rules and run in the callbacks.
 */
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  GROUP_TONES,
  civilOf,
  isInstrumentId,
  readBookingFields,
  toResources,
  type BookingEvent,
} from './adapter'
import { INITIAL_VIEW_START, LAB_HOURS, WEEK, type Block, type InstrumentId } from './model'
import { blockAt, instrumentOf, moveProblem, type MoveProblem } from './rules'
import { BookingBar, InstrumentHeader } from './instrument-slots'
import type { BenchlabText, ZoomId } from './text'
import './lab.css'

export interface BenchlabSchedulerProps {
  readonly events: BookingEvent[]
  readonly blocks: readonly Block[]
  /** Bookings that overlap unavailable time. */
  readonly collisions: readonly string[]
  readonly text: BenchlabText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Level on mount. Later changes go through control.zoom so the view animates from where it is. */
  readonly initialZoom: ZoomId
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** A run dragged across free time: the application plans the booking around it. */
  readonly onRequest: (instrument: InstrumentId, start: string, end: string) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 56,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 180,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'days']

export function BenchlabScheduler(props: BenchlabSchedulerProps) {
  const {
    events,
    blocks,
    collisions,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    history,
    controlRef,
  } = props
  const { onEventsChange, onRequest, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  // Header labels follow the active level. An animated zoom renders the new level's headers before
  // control.scale reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
  const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
  // Narrower row headers on phones (no icons, smaller indent), chosen once on mount.
  const compact = useMemo(() => window.matchMedia('(max-width: 639px)').matches, [])

  const config = useMemo<SchedulerProps>(() => {
    const colliding = new Set(collisions)
    const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
    const refusal = (problem: MoveProblem) =>
      problem === 'other-model'
        ? text.rules.otherModel
        : problem === 'unavailable'
          ? text.rules.unavailable
          : text.rules.tooShort

    return {
      locale: schedulerLocale,
      startDate: WEEK.start,
      days: WEEK.days,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The lab is open 07:00–20:00: nights stay hidden on the hour level.
      businessBeginsHour: LAB_HOURS.begin,
      businessEndsHour: LAB_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: compact ? 144 : 216,
      rowHeaderWidthAutoFit: false,
      treeIndent: compact ? 10 : 20,
      eventHeight: 30,
      rowMarginTop: 3,
      rowMarginBottom: 3,
      allowEventOverlap: false,
      // Selecting free time (pointer drag or keyboard) asks for a booking; plain clicks do not.
      timeRangeSelectedHandling: 'Enabled',
      dragCard: {
        duration: false,
        dateFormat: pattern,
        movingDateFormat: pattern,
        markerFormat: pattern,
      },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
      },

      // Unavailable time: hour cells inside a window are disabled (hatched, drops refused by the
      // library); a day cell that only partly overlaps one is tinted.
      onBeforeCellRender: (args) => {
        const instrument = args.cell.resource
        if (!isInstrumentId(instrument)) return
        const start = args.cell.start.value
        const end = args.cell.end.value
        const block = blockAt(instrument, start, end, blocks)
        if (block === undefined) return
        if (
          parseCivil(block.start) <= parseCivil(start) &&
          parseCivil(end) <= parseCivil(block.end)
        ) {
          args.cell.properties.disabled = true
          return
        }
        args.cell.properties.cssClass = 'bl-unavailable'
        args.cell.properties.html = `<span class="block truncate px-1.5 pt-1 text-[11px] text-ink-3">${SuperScheduler.Util.escapeHtml(text.blocks[block.kind])}</span>`
      },

      onBeforeEventRender: (args) => {
        const fields = readBookingFields(args.data)
        if (fields === null) return
        // HTML fallback shown until the React bar (renderEvent) mounts.
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.project)}</span></span>`
        const classes = ['ss-ev', toneClass(GROUP_TONES[fields.group])]
        if (colliding.has(String(args.data.id)))
          classes.push('ss-ev--late', 'outline-2', 'outline-offset-1', 'outline-danger')
        args.data.cssClass = classes.join(' ')
        // The preparation is drawn by the library as an area anchored in time at the booking start.
        args.data.areas =
          fields.prepMinutes === 0
            ? []
            : [
                {
                  start: args.data.start,
                  end: args.data.start.addMinutes(fields.prepMinutes),
                  top: 0,
                  bottom: 0,
                  cssClass: 'bl-prep',
                  action: 'None',
                },
              ]
      },

      // Identical instruments only, never across unavailable time; overlap is refused by the library.
      onEventMoving: (args) => {
        const fields = readBookingFields(args.e.data)
        const from = args.e.resource()
        if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.resource)) return
        const problem = moveProblem(
          { instrument: from, prepMinutes: fields.prepMinutes },
          args.resource,
          args.start.value,
          args.end.value,
          blocks,
        )
        if (problem !== null) {
          args.allowed = false
          args.message = refusal(problem)
        }
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const fields = readBookingFields(args.e.data)
        const from = args.e.resource()
        if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.newResource)) return
        const problem = moveProblem(
          { instrument: from, prepMinutes: fields.prepMinutes },
          args.newResource,
          args.newStart.value,
          args.newEnd.value,
          blocks,
        )
        if (problem !== null) {
          args.preventDefault()
          onRefused(refusal(problem))
        }
      },

      // The preparation belongs to the instrument: only the end of a booking can be dragged.
      onEventResizing: (args) => {
        const fields = readBookingFields(args.e.data)
        const instrument = args.e.resource()
        if (fields === null || !isInstrumentId(instrument)) return
        const problem =
          args.what === 'start'
            ? null
            : moveProblem(
                { instrument, prepMinutes: fields.prepMinutes },
                instrument,
                args.start.value,
                args.end.value,
                blocks,
              )
        if (args.what === 'start' || problem !== null) {
          args.allowed = false
          args.message = problem === null ? text.rules.startEdge : refusal(problem)
        }
      },

      onEventResize: (args) => {
        const fields = readBookingFields(args.e.data)
        const instrument = args.e.resource()
        if (fields === null || !isInstrumentId(instrument)) return
        const problem = moveProblem(
          { instrument, prepMinutes: fields.prepMinutes },
          instrument,
          args.newStart.value,
          args.newEnd.value,
          blocks,
        )
        if (args.what === 'start' || problem !== null) {
          args.preventDefault()
          onRefused(problem === null ? text.rules.startEdge : refusal(problem))
        }
      },

      onTimeRangeSelected: (args) => {
        args.control.clearSelection()
        if (args.origin === 'click' || !isInstrumentId(args.resource)) return
        onRequest(args.resource, civilOf(args.start), civilOf(args.end))
      },

      onEventMoved: (args) => onMoved(String(args.e.id())),
      onEventResized: (args) => onResized(String(args.e.id())),
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        setHeaderHook(() => createHeaderHook(tag))
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    history,
    controlRef,
    blocks,
    collisions,
    compact,
    onEventsChange,
    onMoved,
    onRefused,
    onRequest,
    onResized,
    onSelect,
    onZoomLevel,
    schedulerLocale,
    headerHook,
    tag,
    text,
    initialZoom,
  ])

  // React row headers: instruments show how long they prepare before every run.
  const renderRowHeader = useMemo<NonNullable<SchedulerProps['renderRowHeader']>>(
    () =>
      ({ row }) => {
        const id = row.id
        if (!isInstrumentId(id)) return row.name
        const instrument = instrumentOf(id)
        const minutes = instrument.prep.minutes
        return (
          <InstrumentHeader
            name={text.instruments[id]}
            area={compact ? null : instrument.area}
            prep={
              minutes === 0
                ? text.noPrep
                : format(text.prepChip, {
                    minutes: plural(text.minutes, minutes, tag),
                    kind: text.prepKinds[instrument.prep.kind],
                  })
            }
          />
        )
      },
    [text, tag, compact],
  )

  // React booking bars: project, group and sample count; a warning icon when the time is unavailable.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const fields = readBookingFields(data)
        if (fields === null) return e.text()
        return (
          <BookingBar
            project={fields.project}
            group={text.groups[fields.group]}
            samples={plural(text.samples, fields.samples, tag)}
            unavailable={collisions.includes(String(e.id()))}
            compact={width < 176}
          />
        )
      },
    [collisions, text, tag],
  )

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={resources}
      events={events}
      renderRowHeader={renderRowHeader}
      renderEvent={renderEvent}
    />
  )
}
Télécharger le code de l’exemple (.zip)L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.

Partager ce scénario

Le lien ouvre cet exemple avec la vue actuelle (échelle et options). Il ne contient jamais vos modifications ni de données personnelles.

Un autre métier, un problème voisinPlanification 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.Lire le cas métierSalles et équipements partagés