Aller au contenu
SuperScheduler

SantéForma (Entreprise fictive)

Rendez-vous d’un cabinet de kinésithérapie

Un patient ne peut pas venir à 10 h. Trouvez le prochain créneau qui respecte pauses et nettoyages.

Échelle de temps
Démo avec données fictives
minutes / heures
Fonctionnalités Pro
Échelle à la minuteRègles de déplacementCréneaux bloquésSlots de rendu ReactPanneau de détail
Forma
KinésithérapieAlba R.Salle 1Marc D.Salle 2Nora S.PlateauThérapie manuelleTeo V.Salle 3Iris M.Salle 4F-2311 Bilan initialF-2318 Rééducation du genouF-2320 Mobilité de l’épauleF-2324 Soin lombaireF-2330 Rééducation du genouF-2333 Bilan de suiviF-2312 Soin lombaireF-2315 Rééducation du genouF-2319 Mobilité de l’épauleF-2322 Bilan initialF-2331 Soin lombaireF-2335 Bilan de suiviF-2313 Exercices thérapeutiquesF-2317 Exercices thérapeutiquesF-2323 Exercices thérapeutiquesF-2332 Exercices thérapeutiquesF-2314 OstéopathieF-2316 OstéopathieF-2321 Bilan initialF-2326 Soin lombaireF-2334 OstéopathieF-2310 Massage sportifF-2325 Massage sportifF-2327 Massage sportifF-2336 Massage sportifjeudi 15 octobre08:0008:3009:0009:3010:0010:3011:0011:3012:0012:3013:0013:3014:0014:3015:0015:3016:0016:3017:0017:30

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

La situation

Forma est un cabinet fictif qui réunit trois kinésithérapeutes, un ostéopathe et une praticienne de massage sportif. Les rendez-vous durent de 15 à 60 minutes, chaque praticien travaille dans une salle, et la journée est coupée par des pauses, une réunion de synthèse et le nettoyage des salles entre deux séances.

Quand un patient appelle pour déplacer un rendez-vous, l’accueil n’a que quelques secondes pour trouver un créneau. Une grille papier ou un agenda générique laisse réserver sur une pause ou confier une séance d’ostéopathie à un kinésithérapeute. Le planning doit refuser ces créneaux et dire pourquoi, y compris à voix haute pour les lecteurs d’écran. Seuls des codes de rendez-vous et des types de soin apparaissent : cette démo ne contient aucune donnée de patient.

Ce que vous allez faireVous tenterez de placer un rendez-vous sur un créneau de nettoyage, entendrez pourquoi il est refusé, le déplacerez sur le premier créneau libre après le nettoyage et ajusterez sa durée par pas de 15 minutes.

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

SuperScheduler fournit

  • Échelles à la minute : cellules de 30 et 15 minutes avec magnétisme, nuits masquées.
  • Cellules désactivées qui refusent les dépôts, et vetos à chaque image (allowed, message) pendant le déplacement ou le redimensionnement.
  • Une carte de déplacement personnalisée et du contenu React dans chaque rendez-vous via super-scheduler/react-render.
  • Prévention des chevauchements par ligne et déplacements complets au clavier, avec annonces.
  • Événements contrôlés via onEventsChange.

Le code de cet exemple décide

  • Quelles disciplines peuvent assurer chaque soin.
  • Les pauses, la réunion et le nettoyage des salles, et le praticien concerné par chacun.
  • Une phrase par motif de refus, annoncée dans une région live polie quand un dépôt est refusé.
  • Le panneau de détail, le temps réservé par jour, la mission et tous les textes de cette page.

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 : tailles de créneau, cellules désactivées à partir des blocages, carte de déplacement, contenu React des rendez-vous et callbacks où les règles refusent un créneau.

scheduler.tsxtsx
/**
 * Forma appointment board: practitioners over two clinic days in 30- or 15-minute slots.
 *
 * Library: minute-scale cells and snapping, disabled cells, drag/resize with per-frame vetoes,
 * a custom drag card, React content inside events (super-scheduler/react-render), keyboard moves.
 * Application: who may deliver which treatment, the clinic's fixed blocks and the sentence that
 * explains every refusal. Those rules live in ./rules and run inside the callbacks below.
 */
import { CheckIcon, Clock3Icon } from 'lucide-react'
import { useMemo, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { MINUTE, civil, formatCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
  TREATMENT_TONES,
  civilOf,
  isAppointmentEvent,
  practitionerOf,
  readAppointmentFields,
  toResources,
  type AppointmentEvent,
} from './adapter'
import { BLOCKS, CLINIC_DAYS, OPENING_HOURS, type Appointment, type Status } from './model'
import { blockAt, slotRefusal, type Refusal } from './rules'
import { ZOOM_IDS, type FormaText, type ZoomId } from './text'

export interface FormaSchedulerProps {
  readonly events: AppointmentEvent[]
  readonly text: FormaText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Slot size on mount. Later changes go through control.zoom so the view animates. */
  readonly initialZoom: ZoomId
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (id: string, refusal: Refusal) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** Slot sizes: snapping follows the cell, so 15-minute cells move appointments by quarters. */
const SLOT_LEVELS: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
  fifteen: {
    scale: 'CellDuration',
    cellDuration: 15,
    cellWidth: 56,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
  thirty: {
    scale: 'CellDuration',
    cellDuration: 30,
    cellWidth: 64,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
}

// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
  id,
  properties: SLOT_LEVELS[id],
}))

/** Every appointment the control holds right now, in the shape the rules read. */
function appointmentsIn(
  control: SuperScheduler.Scheduler,
): Pick<Appointment, 'id' | 'practitioner' | 'start' | 'end'>[] {
  return control.events.list.flatMap((event) => {
    const practitioner = event.resource === undefined ? null : practitionerOf(event.resource)
    if (practitioner === null || !isAppointmentEvent(event)) return []
    return [
      {
        id: String(event.id),
        practitioner: practitioner.id,
        start: civilOf(event.start),
        end: civilOf(event.end),
      },
    ]
  })
}

/** Checks one proposed slot of an appointment against the clinic's rules. */
function refusalFor(
  control: SuperScheduler.Scheduler | null,
  data: object,
  id: string,
  resource: SuperScheduler.ResourceId,
  start: SuperScheduler.Date,
  end: SuperScheduler.Date,
): Refusal | null {
  const fields = readAppointmentFields(data)
  const practitioner = practitionerOf(resource)
  if (control === null || fields === null || practitioner === null) return null
  return slotRefusal(
    { id, treatment: fields.treatment, start: civil(start.ticks), end: civil(end.ticks) },
    practitioner,
    BLOCKS,
    appointmentsIn(control),
  )
}

export function FormaScheduler(props: FormaSchedulerProps) {
  const { events, text, tag, schedulerLocale, initialZoom, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])

  // The drag card shows a refusal while the shadow sits on a forbidden slot; the hook announces
  // the last reason when the pointer is released without a drop, so it is heard, not only seen.
  const refusal = useDropRefusal(onRefused)

  const config = useMemo<SchedulerRenderProps>(() => {
    const time = (date: SuperScheduler.Date) =>
      formatCivil(date.ticks, tag, { hour: 'numeric', minute: '2-digit' })
    const day = (date: SuperScheduler.Date) => formatCivil(date.ticks, tag, { weekday: 'short' })
    const escape = SuperScheduler.Util.escapeHtml

    return {
      locale: schedulerLocale,
      startDate: CLINIC_DAYS.start,
      days: CLINIC_DAYS.count,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The clinic is closed at night: only opening hours are drawn.
      businessBeginsHour: OPENING_HOURS.begin,
      businessEndsHour: OPENING_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: 176,
      rowHeaderWidthAutoFit: false,
      eventHeight: 44,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      allowEventOverlap: false,
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      // A receptionist reads minutes, not nights: the card shows the practitioner, the new
      // times and, on a forbidden slot, the reason set in onEventMoving.
      dragCard: {
        html: (info) => {
          const minutes = Math.round((info.end.ticks - info.start.ticks) / MINUTE)
          const range = `${day(info.start)} ${time(info.start)}–${time(info.end)}`
          const meta = info.refusal ?? text.durationShort.replace('{count}', String(minutes))
          return (
            `<div class="super-scheduler__drag-card-target">${escape(info.rowName ?? '')}</div>` +
            `<div class="super-scheduler__drag-card-range"><span data-edge="moving">${escape(range)}</span></div>` +
            `<div class="super-scheduler__drag-card-meta"><span class="super-scheduler__drag-card-meta-part">${escape(meta)}</span></div>`
          )
        },
      },

      onBeforeTimeHeaderRender: createHeaderHook(tag),

      onBeforeRowHeaderRender: (args) => {
        const practitioner = practitionerOf(args.row.id)
        if (practitioner === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        args.row.html =
          `<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
          `<span class="truncate font-medium">${escape(args.row.name)}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3">${escape(text.rooms[practitioner.room])}</span></span>`
      },

      // Breaks, meetings and cleaning become disabled cells: the library refuses drops on them.
      onBeforeCellRender: (args) => {
        const practitioner = practitionerOf(args.cell.resource)
        if (practitioner === null) return
        const cellStart = civil(args.cell.start.ticks)
        const block = blockAt(BLOCKS, practitioner.id, cellStart, civil(args.cell.end.ticks))
        if (block === null) return
        args.cell.properties.disabled = true
        if (cellStart === block.start) {
          // The engine clips cells with an inline style; the important utility lets the label of a
          // block's first cell run into the next one at the 15-minute scale.
          args.cell.properties.cssClass = 'overflow-visible!'
          args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-1 text-[10.5px] font-semibold text-ink-3">${escape(text.blocks[block.kind])}</span>`
        }
      },

      onBeforeEventRender: (args) => {
        const fields = readAppointmentFields(args.data)
        if (fields === null) return
        const classes = ['ss-ev', toneClass(TREATMENT_TONES[fields.treatment])]
        if (fields.status === 'unconfirmed') classes.push('ss-ev--ghost')
        args.data.cssClass = classes.join(' ')
      },

      // React content inside each bar: code, status icon and treatment, sized to the bar.
      renderEvent: ({ e, width }) => {
        const fields = readAppointmentFields(e.data)
        if (fields === null) return null
        return (
          <AppointmentContent
            code={String(e.id())}
            treatment={text.treatments[fields.treatment]}
            status={fields.status}
            statusLabel={text.statuses[fields.status]}
            width={width}
          />
        )
      },

      onEventMoving: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          controlRef.current,
          args.e.data,
          id,
          args.resource,
          args.start,
          args.end,
        )
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = text.rules[reason]
        }
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          args.control,
          args.e.data,
          id,
          args.newResource,
          args.newStart,
          args.newEnd,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const resource = args.e.resource()
        const reason =
          resource === undefined
            ? null
            : refusalFor(controlRef.current, args.e.data, id, resource, args.start, args.end)
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = text.rules[reason]
        }
      },

      onEventResize: (args) => {
        const id = String(args.e.id())
        const resource = args.e.resource()
        const reason =
          resource === undefined
            ? null
            : refusalFor(args.control, args.e.data, id, resource, args.newStart, args.newEnd)
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

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

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    controlRef,
    initialZoom,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    refusal,
    schedulerLocale,
    tag,
    text,
  ])

  return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}

/** What a bar shows, by width: icon, code and treatment when there is room, colour alone when not. */
function AppointmentContent({
  code,
  treatment,
  status,
  statusLabel,
  width,
}: {
  code: string
  treatment: string
  status: Status
  statusLabel: string
  width: number
}) {
  if (width > 0 && width < 40) return null
  const StatusIcon = status === 'confirmed' ? CheckIcon : Clock3Icon
  return (
    <span className="flex min-w-0 flex-col gap-0.5 leading-tight">
      <span className="flex min-w-0 items-center gap-1 font-mono text-[11px] font-semibold tracking-tight">
        {width >= 80 ? <StatusIcon aria-label={statusLabel} className="size-3 shrink-0" /> : null}
        <span className="truncate">{code}</span>
      </span>
      <span className="truncate text-[11.5px] font-medium opacity-80">{treatment}</span>
    </span>
  )
}
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 voisinRé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.Lire le cas métierCliniques et santé