Zum Inhalt springen
SuperScheduler

BeherbergungCasa Noma (Fiktives Unternehmen)

Zimmerplanung im Hotel

In Zimmer 104 tropft die Dusche. Buchen Sie den nächsten Gast um, sperren Sie das Zimmer für den Installateur und finden Sie die ausgebuchten Nächte.

Zeitskala
Demo mit synthetischen Daten
Tage / Monate
Pro-Funktionen
RessourcenbaumZiehen und Dauer ändernMinimapGesperrte ZeitenTheming
Casa Noma
Standard-Doppel101Straße102Straße103Innenhof104InnenhofSuperior-Doppel201Balkon202Balkon203InnenhofJunior-Suite301Terrasse302TerrasseGartensuite401GartenHaddadNovakFerreiraSatoBrandtO’NeillJensenDuboisTanakaLindgrenSilvaKayaOkaforBianchiPetrovVargaLaineMorenoAußer BetriebKellerNakamuraFischerOrtegaWrightCostaKimHaasIvanovaDuarteQuinnAlbersSousaVidalHartEriksenNavarroOlsenSalehRinaldiParkMercerAndersenGarcíaBecker26. Okt.2. Nov.9. Nov.16. Nov.Mo., 26.Di., 27.Mi., 28.Do., 29.Fr., 30.Sa., 31.So., 1.Mo., 2.Di., 3.Mi., 4.Do., 5.Fr., 6.Sa., 7.So., 8.Mo., 9.Di., 10.Mi., 11.Do., 12.Fr., 13.Sa., 14.So., 15.Mo., 16.Di., 17.

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Casa Noma ist ein fiktives Boutiquehotel mit zehn Zimmern: vier Standard-Doppelzimmer, drei Superior-Zimmer, zwei Junior-Suiten und eine Gartensuite. Die Rezeption plant auf einem Zimmerplan mit einer Zeile pro Zimmer und einer Spalte pro Nacht. Gäste reisen um 14:00 an und um 11:00 ab: Am Wechseltag endet ein Aufenthalt und der nächste beginnt in derselben Spalte, ohne sich zu überschneiden.

Zimmer fallen aus. Muss eines aus dem Verkauf, muss die Rezeption die dort gebuchten Gäste umziehen, ohne jemanden herabzustufen, die Reparatur zwischen eine Abreise und eine Anreise legen und die Nächte im Blick behalten, die schon voll sind. Balken allein unterscheiden keine ruhige von einer ausgebuchten Nacht; dieser Plan zählt die freien Zimmer pro Typ und Nacht.

Was Sie tun werdenSie verlegen eine Reservierung aus einem defekten Zimmer (ein Upgrade ist erlaubt, eine Herabstufung nicht), verlängern die Reparatur zu echten Hotelzeiten auf drei Nächte und finden die ausgebuchten Nächte über den Auslastungsstreifen.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Ressourcenbaum aus Zimmertypen und Zimmern; die Gruppenzeilen zeigen über onBeforeCellRender die freien Zimmer pro Nacht.
  • Aufenthalte zu exakten Zeiten auf einer Nachtskala (useEventBoxes: 'Never'), die mit eventMoveByCell nächteweise verschoben werden.
  • Deaktivierte Zellen für die geschlossene Gartensuite, Überlappungsschutz und eine Änderung der Dauer, die sich in onEventResizing steuern lässt.
  • Minimap mit Ihrer eigenen Datenreihe, Zoomstufen, Tastaturbedienung, Rückgängig und Wiederholen.
  • Theming mit Tokens und Klassen, beschränkt auf das Wurzelelement (cssClass): Aufenthalte als Pillen, Statusfarben, die Spalte von heute.

Der Code dieses Beispiels entscheidet

  • Upgrade ja, Herabstufung nein, Zimmerkapazität und keine Anreise in der Vergangenheit: Ablehnungen in onEventMoving und onEventResizing.
  • Hotelzeiten beim Ändern der Dauer: Aufenthalte enden um 11:00, Reparaturen um 14:00, nie weniger als eine Nacht.
  • Der Rezeptionsstatus jedes Aufenthalts (abgereist, im Haus, reist heute an, bestätigt, Option) ab dem Geschäftsdatum.
  • Auslastung pro Nacht, freie Zimmer pro Typ und Nacht und die Übersicht der nächsten sieben Nächte.

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: Nachtskala mit exakten Zeiten, Verfügbarkeitszellen, gesperrte Tage und die Callbacks, in denen Rezeptionsregeln eine Änderung ablehnen oder einrasten.

scheduler.tsxtsx
/**
 * Casa Noma tape chart: rooms grouped by room type over four weeks, one column per night.
 *
 * Library: resource tree, exact-time stays (check-in 14:00, check-out 11:00) on a day scale,
 * drag/resize, disabled cells, cell and event rendering hooks, zoom levels, keyboard and history.
 * Application: upgrades and downgrades, capacity, hotel times for resized edges, the out-of-order
 * room and the free rooms per type and night. Those rules live in ./rules and run below.
 */
import { useCallback, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerBeforeTimeHeaderRenderArgs,
  SchedulerEventsChangeArgs,
  SchedulerProps,
} from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { plural } from '../../i18n/format'
import { DAY, parseCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  CHART_GEOMETRY,
  STATUS_TONES,
  isItemEvent,
  isRoomTypeId,
  readItemFields,
  toItem,
  toResources,
  type ItemEvent,
} from './adapter'
import { BUSINESS_NOW, CHART, CLOSURES, type RoomItem } from './model'
import {
  freeByType,
  isClosed,
  isUpgrade,
  moveRefusal,
  nightsOf,
  resizeTo,
  roomById,
  statusOf,
  type MoveRefusal,
} from './rules'
import type { CasaNomaText, ZoomId } from './text'

export interface CasaNomaSchedulerProps {
  readonly events: ItemEvent[]
  /** The same events as business objects, for the availability cells. */
  readonly items: readonly RoomItem[]
  readonly text: CasaNomaText
  /** 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
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string, edge: 'start' | 'end') => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** One night per column: a fortnight to work in, the whole month to look ahead. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'fortnight',
    properties: {
      scale: 'Day',
      cellWidth: CHART_GEOMETRY.cellWidth,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
  {
    id: 'month',
    properties: {
      scale: 'Day',
      cellWidth: 30,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['fortnight', 'month']
const CHART_START = parseCivil(CHART.start)
const TODAY = parseCivil(BUSINESS_NOW.slice(0, 10))
const NOW = parseCivil(BUSINESS_NOW)

function readItem(data: SuperScheduler.EventData): RoomItem | null {
  return isItemEvent(data) ? toItem(data) : null
}

export function CasaNomaScheduler(props: CasaNomaSchedulerProps) {
  const { events, items, text, tag, schedulerLocale, initialZoom, history, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

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

  // Day labels shorten below a cell width. A zoom animation lands after the headers of its last
  // frames were drawn, so each settled level gets a fresh hook: a new hook makes the control label
  // its headers again. Today's column is marked on top of the shared labels.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => {
    const labels = createHeaderHook(tag)
    return {
      level,
      hook: (args: SchedulerBeforeTimeHeaderRenderArgs) => {
        labels(args)
        if (args.header.level === 1 && args.header.start.getTime() === TODAY)
          args.header.cssClass = 'cn-today'
      },
    }
  }, [level, tag]).hook
  const free = useMemo(() => freeByType(items, CLOSURES, CHART.start, CHART.days), [items])

  // A change refused while dragging never reaches onEventMove/onEventResize: announce the refusal
  // of the last drag frame when the pointer is released (a frame that is allowed again clears it).
  const refusal = useRef<string | null>(null)
  const refuseOnRelease = useCallback(
    (message: string | null) => {
      if (message !== null && refusal.current === null) {
        window.addEventListener(
          'pointerup',
          () => {
            setTimeout(() => {
              if (refusal.current !== null) onRefused(refusal.current)
              refusal.current = null
            }, 0)
          },
          { once: true },
        )
      }
      refusal.current = message
    },
    [onRefused],
  )

  const config = useMemo<SchedulerProps>(() => {
    const refusalText = (reason: MoveRefusal | null) =>
      reason === null ? null : text.rules[reason]

    return {
      locale: schedulerLocale,
      startDate: CHART.start,
      days: CHART.days,
      // Hotel weeks run Monday to Sunday in every locale (as the static preview draws them).
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Exact hotel times on a night scale: a stay is drawn from 14:00 to 11:00 and keeps those
      // times when it is dragged by whole nights. Resized edges are snapped by the hotel rules.
      useEventBoxes: 'Never',
      eventMoveByCell: true,
      snapToGridEventResizing: false,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: CHART_GEOMETRY.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: CHART_GEOMETRY.eventHeight,
      rowMarginTop: CHART_GEOMETRY.rowMarginTop,
      rowMarginBottom: CHART_GEOMETRY.rowMarginBottom,
      allowEventOverlap: false,
      // The Casa Noma skin (theme.css) is scoped to the control root.
      cssClass: 'casa-noma',
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      // Room types are group rows; each room shows its view as a detail.
      onBeforeRowHeaderRender: (args) => {
        const room = roomById(String(args.row.id))
        if (room === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const name = SuperScheduler.Util.escapeHtml(args.row.name)
        const view = SuperScheduler.Util.escapeHtml(text.views[room.view])
        args.row.html =
          `<span class="flex min-w-0 items-baseline justify-between gap-2"><span class="truncate">${name}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3">${view}</span></span>`
      },

      // Group rows show the rooms still free each night; closed days disable a room's cells.
      onBeforeCellRender: (args) => {
        const { cell } = args
        const day = cell.start.getTime()
        const today = day === TODAY ? ' cn-today' : ''
        if (isRoomTypeId(cell.resource)) {
          const count = free[cell.resource][Math.round((day - CHART_START) / DAY)]
          if (count === undefined) return
          cell.properties.html = `<span class="cn-free">${count === null ? '–' : count}</span>`
          cell.properties.cssClass = `cn-avail${count === 0 ? ' cn-full' : ''}${today}`
          return
        }
        if (isClosed(String(cell.resource), day, CLOSURES)) cell.properties.disabled = true
        else if (today !== '') cell.properties.cssClass = 'cn-today'
      },

      onBeforeEventRender: (args) => {
        const fields = readItemFields(args.data)
        if (fields === null) return
        const start = args.data.start.value
        const end = args.data.end.value
        if (fields.kind === 'repair') {
          const title = SuperScheduler.Util.escapeHtml(text.outOfOrder)
          const nights = SuperScheduler.Util.escapeHtml(
            plural(text.nights, nightsOf(start, end), tag),
          )
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${nights}</span></span>`
          args.data.cssClass = `ss-ev cn-item cn-repair ss-ev--hatched ${toneClass('blocked')}`
          return
        }
        const status = statusOf(
          { start, end, ...(fields.option === true ? { option: true } : {}) },
          BUSINESS_NOW,
        )
        const room = args.data.resource
        const upgrade =
          room !== undefined && isUpgrade({ room: String(room), booked: fields.booked })
        const guest = SuperScheduler.Util.escapeHtml(fields.guest)
        const nights = SuperScheduler.Util.escapeHtml(
          plural(text.nights, nightsOf(start, end), tag),
        )
        const badge = upgrade
          ? `<span class="ss-evt__badge">${SuperScheduler.Util.escapeHtml(text.upgrade)}</span>`
          : ''
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${guest}</span><span class="ss-evt__meta">${nights}</span>${badge}</span>`
        const classes = [
          'ss-ev',
          'cn-item',
          'cn-stay',
          `cn-${status}`,
          toneClass(STATUS_TONES[status]),
        ]
        if (status === 'option') classes.push('ss-ev--ghost')
        args.data.cssClass = classes.join(' ')
      },

      // Categories, capacity and the past are front-desk rules; overlap and closed days are the library's.
      onEventMoving: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const message = refusalText(
          moveRefusal(item, String(args.resource), BUSINESS_NOW, args.start.getTime()),
        )
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        refuseOnRelease(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const message = refusalText(
          moveRefusal(item, String(args.newResource), BUSINESS_NOW, args.newStart.getTime()),
        )
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
        }
      },

      // Edges snap to hotel times: stays end at 11:00 and start at 14:00, repairs the other way round.
      onEventResizing: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const edge = args.what
        const next = resizeTo(item, edge, (edge === 'end' ? args.end : args.start).getTime())
        args.start = new SuperScheduler.Date(next.start)
        args.end = new SuperScheduler.Date(next.end)
        const message = edge === 'start' ? startRefusal(item, next.start, text) : null
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        refuseOnRelease(message)
      },

      onEventResize: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const next = resizeTo(
          item,
          args.what,
          (args.what === 'end' ? args.newEnd : args.newStart).getTime(),
        )
        const message = args.what === 'start' ? startRefusal(item, next.start, text) : null
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
          return
        }
        args.newStart = new SuperScheduler.Date(next.start)
        args.newEnd = new SuperScheduler.Date(next.end)
      },

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

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

  return (
    <SuperSchedulerComponent
      {...config}
      onBeforeTimeHeaderRender={timeHeaders}
      resources={resources}
      events={events}
    />
  )
}

/** A guest in the house keeps the check-in; a guest still to come cannot check in in the past. */
function startRefusal(item: RoomItem, start: number, text: CasaNomaText): string | null {
  if (item.kind !== 'stay') return null
  const status = statusOf(item, BUSINESS_NOW)
  if (status === 'in-house') return text.rules.checkedIn
  return start < NOW ? text.rules.past : null
}
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 ProblemPlanung einer MietwagenflotteEin Kleinwagen fällt am Abholtag aus. Die Miete auf ein anderes Auto legen, die Aufbereitung einhalten und sehen, wo die Flotte knapp wird.Zum AnwendungsfallBeherbergung und Vermietung