Zum Inhalt springen
SuperScheduler

GesundheitForma (Fiktives Unternehmen)

Termine in der Physiotherapiepraxis

Ein Patient kann um 10 Uhr nicht. Finden Sie den nächsten Slot, der Pausen und Reinigung respektiert.

Zeitskala
Demo mit synthetischen Daten
Minuten / Stunden
Pro-Funktionen
MinutenrasterVerschieberegelnGesperrte ZeitenReact-Render-SlotsDetailpanel
Forma
PhysiotherapieAlba R.Raum 1Marc D.Raum 2Nora S.TrainingsraumManuelle TherapieTeo V.Raum 3Iris M.Raum 4F-2311 ErstbefundF-2318 Knie-RehaF-2320 SchultermobilisationF-2324 RückenbehandlungF-2330 Knie-RehaF-2333 VerlaufskontrolleF-2312 RückenbehandlungF-2315 Knie-RehaF-2319 SchultermobilisationF-2322 ErstbefundF-2331 RückenbehandlungF-2335 VerlaufskontrolleF-2313 TrainingstherapieF-2317 TrainingstherapieF-2323 TrainingstherapieF-2332 TrainingstherapieF-2314 OsteopathieF-2316 OsteopathieF-2321 ErstbefundF-2326 RückenbehandlungF-2334 OsteopathieF-2310 SportmassageF-2325 SportmassageF-2327 SportmassageF-2336 SportmassageDonnerstag, 15. Oktober08:0008:3009:0009:3010:0010:3011:0011:3012:0012:3013:0013:3014:0014:3015:0015:3016:0016:3017:0017:30

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Forma ist eine fiktive Praxis mit drei Physiotherapeutinnen und Physiotherapeuten, einem Osteopathen und einer Sportmasseurin. Termine dauern 15 bis 60 Minuten, jede Fachkraft arbeitet in einem festen Raum, und der Tag wird von Pausen, einer Fallbesprechung und der Raumreinigung zwischen den Behandlungen unterbrochen.

Ruft ein Patient an, um einen Termin zu verlegen, hat der Empfang nur Sekunden für einen freien Slot. Ein Papierraster oder ein allgemeiner Kalender lässt Termine in Pausen zu oder gibt eine Osteopathie-Sitzung an einen Physiotherapeuten. Die Planungstafel muss solche Slots ablehnen und sagen, warum, auch hörbar für Screenreader. Angezeigt werden nur Terminnummern und Behandlungsarten: Diese Demo enthält keine Patientendaten.

Was Sie tun werdenSie versuchen, einen Termin in ein Reinigungsfenster zu legen, hören, warum das abgelehnt wird, verschieben ihn in den ersten freien Slot nach der Reinigung und passen seine Dauer in 15-Minuten-Schritten an.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Minutenskalen: 30- und 15-Minuten-Zellen mit Einrasten, Nachtstunden ausgeblendet.
  • Deaktivierte Zellen, die das Ablegen verweigern, und Vetos pro Frame (allowed, message) beim Ziehen und beim Ändern der Dauer.
  • Eine eigene Drag-Karte und React-Inhalte in jedem Termin über super-scheduler/react-render.
  • Überlappungsschutz pro Zeile und vollständige Tastaturbedienung mit Ansagen.
  • Kontrollierte Ereignisse über onEventsChange.

Der Code dieses Beispiels entscheidet

  • Welche Fachrichtung welche Behandlung erbringen darf.
  • Pausen, Besprechung und Raumreinigung der Praxis, und für welche Fachkraft sie jeweils gelten.
  • Ein vollständiger Satz pro Ablehnungsgrund, angesagt in einer höflichen Live-Region, wenn das Ablegen scheitert.
  • Das Detailpanel, die gebuchte Zeit pro Tag, die Aufgabe und alle Texte dieser Seite.

Der Code hinter dieser Demo

Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.

Die Integration: Slotgrößen, deaktivierte Zellen aus der Sperrliste, die Drag-Karte, React-Inhalte der Termine und die Callbacks, in denen die Regeln einen Slot ablehnen.

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>
  )
}
Quellcode des Beispiels herunterladen (.zip)Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.

Dieses Szenario teilen

Der Link öffnet dieses Beispiel mit der aktuellen Ansicht (Zeitskala und Optionen). Er enthält nie Ihre Änderungen oder persönliche Daten.

Eine andere Branche, ein verwandtes ProblemPlatzbuchung im SportclubAm Vormittag reißt ein Padel-Netz. Kurs verschieben, Platz sperren und jeden Coach in seinem Dienst lassen.Zum AnwendungsfallPraxen und Gesundheit